M9.13: Card Dance / Shatter / Card Wipe camera systems, lighting and material

- card3d: shared Camera System (Camera Position with rotations, X/Y position and
  Transform Order; Corner Pins with focal length; Comp Camera via EffectHost::comp_scene),
  Lighting (Distant / Point / First Comp Light, colour, position, depth, ambient) and
  Material (diffuse, specular, highlight sharpness).
- Shatter: Custom Shatter Map (+ White Tiles Fixed), Gradient group, Textures group
  (front/side/back modes and layers, colour, opacity) and extrusion side rendering.
- Card Wipe: true 3D cards, Back Layer as a layer parameter (old None/Self popup migrated),
  Flip Direction, Position / Rotation Jitter.
- Renderer supplies the comp camera and first light to effects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 02:59:10 -04:00
1 parent 396268496b
commit 0ed5c87b94
10 files changed
+1118 -145

No files matched your search

+413
View File
@@ -0,0 +1,413 @@
//! The 3D system shared by Card Dance, Shatter and Card Wipe: Camera System (Camera Position,
//! Corner Pins, Comp Camera), Lighting (Distant Source, Point Source, First Comp Light) and
//! Material (diffuse, specular, highlight sharpness).
//!
//! 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.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::sim::pt;
use crate::{Buf, EffectCtx, ParamSpec, col, num, p, popup, slider};
pub(crate) type M3 = [[f64; 3]; 3];
/// Camera System options.
pub const CAMERA_SYSTEMS: [&str; 3] = ["Camera Position", "Corner Pins", "Comp Camera"];
/// Transform Order options (rotation order, and whether the camera position applies before or
/// after the rotation).
pub const TRANSFORM_ORDERS: [&str; 12] = [
"Rotate XYZ, Position",
"Rotate XZY, Position",
"Rotate YXZ, Position",
"Rotate YZX, Position",
"Rotate ZXY, Position",
"Rotate ZYX, Position",
"Position, Rotate XYZ",
"Position, Rotate XZY",
"Position, Rotate YXZ",
"Position, Rotate YZX",
"Position, Rotate ZXY",
"Position, Rotate ZYX",
];
/// Light Type options.
pub const LIGHT_TYPES: [&str; 3] = ["Distant Source", "Point Source", "First Comp Light"];
/// Camera System, Camera Position and Corner Pins parameters. `z` is the default camera
/// distance (Z Position, in layer sizes at the 70 mm-equivalent Focal Length).
pub(crate) fn camera_params(z: f64) -> Vec<ParamSpec> {
vec![
p("cameraSystem", "Camera System", Value::Enum(0), popup(&CAMERA_SYSTEMS)),
p("cameraPosition/xRotation", "X Rotation", num(0.0), ParamUi::Angle),
p("cameraPosition/yRotation", "Y Rotation", num(0.0), ParamUi::Angle),
p("cameraPosition/zRotation", "Z Rotation", num(0.0), ParamUi::Angle),
p("cameraPosition/xyPosition", "X, Y Position", pt(0.5, 0.5), ParamUi::Point),
p("cameraPosition/cameraZ", "Z Position", num(z), slider(0.05, 10.0, 0.5, 5.0, 2)),
p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
p("cameraPosition/transformOrder", "Transform Order", Value::Enum(0), popup(&TRANSFORM_ORDERS)),
p("cornerPins/upperLeftCorner", "Upper Left Corner", pt(0.0, 0.0), ParamUi::Point),
p("cornerPins/upperRightCorner", "Upper Right Corner", pt(1.0, 0.0), ParamUi::Point),
p("cornerPins/lowerLeftCorner", "Lower Left Corner", pt(0.0, 1.0), ParamUi::Point),
p("cornerPins/lowerRightCorner", "Lower Right Corner", pt(1.0, 1.0), ParamUi::Point),
p("cornerPins/autoFocalLength", "Auto Focal Length", Value::Bool(true), ParamUi::Checkbox),
p("cornerPins/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
]
}
/// Lighting parameters.
pub(crate) fn lighting_params(intensity: f64, ambient: f64) -> Vec<ParamSpec> {
vec![
p("lighting/lightType", "Light Type", Value::Enum(0), popup(&LIGHT_TYPES)),
p("lighting/lightIntensity", "Light Intensity", num(intensity), slider(0.0, 4.0, 0.0, 2.0, 2)),
p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lighting/lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
p("lighting/lightDepth", "Light Depth", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 3)),
p("lighting/ambientLight", "Ambient Light", num(ambient), slider(0.0, 2.0, 0.0, 1.0, 2)),
]
}
/// Material parameters. Specular defaults to 0 so pieces at rest reproduce the layer.
pub(crate) fn material_params(diffuse: f64) -> Vec<ParamSpec> {
vec![
p("material/diffuse", "Diffuse Reflection", num(diffuse), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/specular", "Specular Reflection", num(0.0), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)),
]
}
/// Whether the camera / lighting parameter `param` is shown for the current choices.
pub(crate) fn shown(param: &str, e: &dyn Fn(&str) -> u32) -> Option<bool> {
if param == "cameraPosition" || param.starts_with("cameraPosition/") {
return Some(e("cameraSystem") == 0);
}
if param == "cornerPins" || param.starts_with("cornerPins/") {
return Some(e("cameraSystem") == 1);
}
if param == "lighting/lightPosition" || param == "lighting/lightDepth" {
return Some(e("lighting/lightType") != 2);
}
None
}
pub(crate) fn mat_mul(a: &M3, b: &M3) -> M3 {
std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
}
fn rx(a: f64) -> M3 {
let (s, c) = a.sin_cos();
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
}
fn ry(a: f64) -> M3 {
let (s, c) = a.sin_cos();
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
}
fn rz(a: f64) -> M3 {
let (s, c) = a.sin_cos();
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
}
/// Rotation applying the axes in `order` (0 XYZ, 1 XZY, 2 YXZ, 3 YZX, 4 ZXY, 5 ZYX; the first
/// letter is applied first). Angles in radians.
pub(crate) fn rot_ordered(x: f64, y: f64, z: f64, order: u32) -> M3 {
let (mx, my, mz) = (rx(x), ry(y), rz(z));
let seq: [&M3; 3] = match order % 6 {
1 => [&mx, &mz, &my],
2 => [&my, &mx, &mz],
3 => [&my, &mz, &mx],
4 => [&mz, &mx, &my],
5 => [&mz, &my, &mx],
_ => [&mx, &my, &mz],
};
mat_mul(seq[2], &mat_mul(seq[1], seq[0]))
}
/// A camera: buffer-world → buffer pixels (homogeneous rows X, Y, W; W is the depth in front
/// of the camera, in the units `near` uses).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Proj {
pub m: [[f64; 4]; 3],
/// Points with W below this are behind / too close to the camera.
pub near: f64,
}
impl Proj {
/// Looking down +z at the layer plane from `d` pixels in front of buffer point `c`.
pub fn simple(c: [f64; 2], d: f64) -> Proj {
Proj { m: [[d, 0.0, c[0], 0.0], [0.0, d, c[1], 0.0], [0.0, 0.0, 1.0, d]], near: 0.05 * d }
}
/// `self` after the affine scene transform `p ↦ a·p + t`.
fn after(&self, a: &M3, t: [f64; 3]) -> Proj {
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = (0..3).map(|k| self.m[i][k] * a[k][j]).sum();
}
m[i][3] = (0..3).map(|k| self.m[i][k] * t[k]).sum::<f64>() + self.m[i][3];
}
Proj { m, near: self.near }
}
/// A screen-space homography applied after `self`.
fn then(&self, h: &M3) -> Proj {
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..4 {
m[i][j] = (0..3).map(|k| h[i][k] * self.m[k][j]).sum();
}
}
// Keep W's scale (and so `near`) comparable when H rescales it.
Proj { m, near: self.near * h[2][2].abs().max(1e-9) }
}
/// Project a buffer-world point (None behind the camera).
#[cfg(test)]
pub fn project(&self, p: [f64; 3]) -> Option<[f64; 2]> {
let r = |i: usize| self.m[i][0] * p[0] + self.m[i][1] * p[1] + self.m[i][2] * p[2] + self.m[i][3];
let w = r(2);
(w > self.near).then(|| [r(0) / w, r(1) / w])
}
/// 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] {
let a: M3 = std::array::from_fn(|i| [self.m[i][0], self.m[i][1], self.m[i][2]]);
match inv3(&a) {
Some(ai) => {
let t = [-self.m[0][3], -self.m[1][3], -self.m[2][3]];
std::array::from_fn(|i| (0..3).map(|k| ai[i][k] * t[k]).sum())
}
None => [0.0, 0.0, -1e9],
}
}
}
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]);
if det.abs() < 1e-12 {
return None;
}
let d = 1.0 / det;
Some([
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d],
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d],
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d],
])
}
/// Homography taking the unit square's corners (0,0) (1,0) (1,1) (0,1) to `q`.
fn square_to_quad(q: [[f64; 2]; 4]) -> M3 {
let v = |p: [f64; 2]| effectcraft_geom::Vec2 { x: p[0], y: p[1] };
effectcraft_geom::Mat3::square_to_quad([v(q[0]), v(q[1]), v(q[2]), v(q[3])]).0
}
/// The camera an effect renders through, from its Camera System parameters.
pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
let pr = ctx.params;
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let c = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s];
let size = lw.max(lh) * s;
let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
match pr.e("cameraSystem") {
1 => {
// Corner Pins: the layer's corners land on the four pins; the focal length sets how
// strongly depth (pieces leaving the plane) is foreshortened.
let focal = if pr.get("cornerPins/autoFocalLength").is_none_or(|v| v.as_bool()) { 70.0 } else { f("cornerPins/focalLength", 70.0).max(1.0) };
let base = Proj::simple(c, size * 2.0 * focal / 70.0);
let pin = |id: &str| {
let (x, y) = b.to_px(pr.v2(id));
[x, y]
};
let quad =
[pin("cornerPins/upperLeftCorner"), pin("cornerPins/upperRightCorner"), pin("cornerPins/lowerRightCorner"), pin("cornerPins/lowerLeftCorner")];
let rect = [
[b.offset[0], b.offset[1]],
[b.offset[0] + lw * s, b.offset[1]],
[b.offset[0] + lw * s, b.offset[1] + lh * s],
[b.offset[0], b.offset[1] + lh * s],
];
match inv3(&square_to_quad(rect)) {
Some(ri) => base.then(&mat_mul(&square_to_quad(quad), &ri)),
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 }
}
_ => {
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
let base = Proj::simple(c, size * zpos * focal / 140.0);
let order = pr.e("cameraPosition/transformOrder");
let r = rot_ordered(
f("cameraPosition/xRotation", 0.0).to_radians(),
f("cameraPosition/yRotation", 0.0).to_radians(),
f("cameraPosition/zRotation", 0.0).to_radians(),
order,
);
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]);
let (px, py) = b.to_px(xy);
let shift = [px - c[0], py - c[1], 0.0];
// Rotate about the layer centre, then move by the X, Y Position offset (or the
// other way round).
let c3 = [c[0], c[1], 0.0];
let rc: [f64; 3] = std::array::from_fn(|i| c3[i] - (0..3).map(|k| r[i][k] * c3[k]).sum::<f64>());
let t = if order >= 6 {
// p ↦ R(p + shift − c) + c
std::array::from_fn(|i| rc[i] + (0..3).map(|k| r[i][k] * shift[k]).sum::<f64>())
} else {
std::array::from_fn(|i| rc[i] + shift[i])
};
base.after(&r, t)
}
}
}
/// 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 {
/// Position in layer pixels (z away from the viewer).
pub pos: [f64; 3],
/// Direction the light travels (parallel / spot lights), unit, layer space.
pub dir: [f64; 3],
/// Colour × intensity (1 = 100 %).
pub color: [f32; 3],
/// 0 parallel, 1 point / spot, 2 ambient.
pub kind: u8,
}
/// The comp's camera and lights relative to the effect's layer.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompScene {
/// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp 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]>,
pub light: Option<CompLight>,
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
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] }
}
/// Lighting and Material, ready to shade pieces.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Lighting {
/// 0 distant, 1 point, 2 ambient only.
kind: u8,
color: [f32; 3],
pos: [f64; 3],
/// Distant light: direction towards the light.
to_light: [f64; 3],
ambient: f32,
kd: f32,
ks: f32,
sharp: f32,
}
impl Lighting {
pub fn from(ctx: &EffectCtx, b: &Buf) -> Lighting {
let pr = ctx.params;
let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let centre = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s, 0.0];
let intensity = f("lighting/lightIntensity", 1.0) as f32;
let lc = pr.get("lighting/lightColor").map(|v| v.as_color()).unwrap_or([1.0; 4]);
let mut color = [lc[0] * intensity, lc[1] * intensity, lc[2] * intensity];
let lp = pr.get("lighting/lightPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]);
let (x, y) = b.to_px(lp);
let mut pos = [x, y, -f("lighting/lightDepth", 1.0) * lw.max(lh) * s];
let mut kind = if pr.e("lighting/lightType") == 1 { 1 } else { 0 };
let mut to_light = norm(sub(pos, centre));
if pr.e("lighting/lightType") == 2 {
match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) {
Some(l) => {
color = [l.color[0] * intensity, l.color[1] * intensity, l.color[2] * intensity];
pos = [l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1], l.pos[2] * s];
kind = l.kind;
to_light = norm([-l.dir[0], -l.dir[1], -l.dir[2]]);
}
// No light in the comp: ambient only.
None => kind = 2,
}
}
Lighting {
kind,
color,
pos,
to_light,
ambient: f("lighting/ambientLight", 0.25) as f32,
kd: f("material/diffuse", 0.75) as f32,
ks: f("material/specular", 0.0) as f32,
sharp: f("material/highlightSharpness", 15.0).max(1.0) as f32,
}
}
/// Multiplier and additive highlight for a piece with unit normal `n` (rotated front normal)
/// at `at`, seen from `eye`. Pieces are lit on whichever side faces the viewer.
pub fn shade(&self, n: [f64; 3], at: [f64; 3], eye: [f64; 3]) -> ([f32; 3], [f32; 3]) {
let v = norm(sub(eye, at));
let n = if dot(n, v) < 0.0 { [-n[0], -n[1], -n[2]] } else { n };
if self.kind == 2 {
let a = self.ambient;
return ([a + self.kd * self.color[0], a + self.kd * self.color[1], a + self.kd * self.color[2]], [0.0; 3]);
}
let l = if self.kind == 1 { norm(sub(self.pos, at)) } else { self.to_light };
let ndl = dot(n, l).max(0.0) as f32;
let h = norm([l[0] + v[0], l[1] + v[1], l[2] + v[2]]);
let spec = if self.ks > 0.0 && ndl > 0.0 { self.ks * (dot(n, h).max(0.0) as f32).powf(self.sharp) } else { 0.0 };
(std::array::from_fn(|k| self.ambient + self.kd * ndl * self.color[k]), std::array::from_fn(|k| spec * self.color[k]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_camera_keeps_the_layer_plane() {
let p = Proj::simple([10.0, 20.0], 100.0);
assert_eq!(p.project([3.0, 4.0, 0.0]), Some([3.0, 4.0]));
let e = p.eye();
assert!((e[0] - 10.0).abs() < 1e-9 && (e[1] - 20.0).abs() < 1e-9 && (e[2] + 100.0).abs() < 1e-9);
// Further away looks smaller (towards the centre).
let q = p.project([30.0, 20.0, 100.0]).unwrap();
assert!((q[0] - 20.0).abs() < 1e-9);
}
#[test]
fn rotation_orders_differ() {
let a = rot_ordered(0.5, 0.3, 0.2, 0);
let b = rot_ordered(0.5, 0.3, 0.2, 5);
assert!((0..3).any(|i| (0..3).any(|j| (a[i][j] - b[i][j]).abs() > 1e-3)));
let z = rot_ordered(0.0, 0.0, 0.0, 3);
assert_eq!(z, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
}
}
+5 -3
View File
@@ -35,15 +35,12 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.noise.removegrain", "no preview region, sampling or temporal filtering"),
("ec.vr.digitalglitch", "reduced control set"),
("ec.vr.converter", "common layouts only"),
("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"),
("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"),
("ec.sim.caustics", "no Sky group or light type"),
("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"),
("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"),
("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"),
("ec.stylize.glow", "no Glow Operation or arbitrary colour map"),
("ec.stylize.cartoon", "no Edge Enhancement"),
("ec.transition.cardwipe", "2D flip only; no back layer picker, camera, lighting or jitter"),
("ec.transition.blockdissolve", "no Soft Edges option"),
];
@@ -79,6 +76,11 @@ pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<eff
_ => true,
}
}
"ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true),
"ec.sim.shatter" => match param {
"shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5,
_ => crate::card3d::shown(param, &e).unwrap_or(true),
},
_ => {
let _ = b("");
true
+16 -1
View File
@@ -10,6 +10,7 @@ pub mod audio_fx;
mod blur2;
mod blur3;
pub mod camera_tracker;
mod card3d;
pub mod catalog;
mod channel;
mod channel2;
@@ -60,6 +61,7 @@ pub mod warp_stab;
use std::collections::HashMap;
use std::sync::OnceLock;
pub use card3d::{CompLight, CompScene};
pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip};
pub use color2::Curve;
use effectcraft_keyframe::Value;
@@ -260,6 +262,11 @@ pub trait EffectHost: Sync {
fn aux(&self) -> Option<std::sync::Arc<AuxChannels>> {
None
}
/// The composition's camera and first light relative to the effect's layer (Card
/// Dance / Shatter / Card Wipe's Comp Camera and First Comp Light). `None` when unknown.
fn comp_scene(&self) -> Option<CompScene> {
None
}
}
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
@@ -573,6 +580,11 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("xScale", "X Scale"),
("yScale", "Y Scale"),
("cameraPosition", "Camera Position"),
("cornerPins", "Corner Pins"),
("positionJitter", "Position Jitter"),
("rotationJitter", "Rotation Jitter"),
("textures", "Textures"),
("gradient", "Gradient"),
("cannon", "Cannon"),
("grid", "Grid"),
("layerExploder", "Layer Exploder"),
@@ -677,6 +689,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.time.ccforcemotionblur",
"ec.time.ccwidetime",
"ec.time.pixelmotionblur",
// Card Wipe's position / rotation jitter moves with time.
"ec.transition.cardwipe",
// Temporal Smoothing reads neighbouring frames.
"ec.color.autolevels",
"ec.color.autocontrast",
@@ -711,7 +725,8 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
#[cfg(test)]
pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
let s = find(id).unwrap_or_else(|| panic!("no effect {id}"));
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let size = [img.width as f64, img.height as f64];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (k, v) in vals {
assert!(params.values.contains_key(*k), "{id}: unknown param {k}");
params.values.insert(k.to_string(), v.clone());
+34
View File
@@ -242,6 +242,18 @@ fn is_numeric_ui(u: &ParamUi) -> bool {
/// point defaults of added parameters. Returns whether anything changed.
pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool {
let before = g.clone();
// Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self"
// kept in the hidden Back Layer Is Self switch.
let mut back_self = None;
if spec.id == "ec.transition.cardwipe"
&& let Some(pr) = g.get_mut("backLayer")
&& matches!(pr.ui, ParamUi::Popup { .. })
{
back_self = Some(pr.value.as_enum() == 1);
pr.value = Value::Layer(None);
pr.keys.clear();
pr.ui = ParamUi::Layer;
}
for ps in &spec.params {
// Locate the stored property: at its spec path, under an old id, or at top level
// before it moved into a twirl-down group.
@@ -296,6 +308,11 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
}
}
}
if let Some(on) = back_self
&& let Some(pr) = g.get_mut("backSelf")
{
pr.value = Value::Bool(on);
}
// Refresh twirl-down group names.
fn names(g: &mut PropGroup) {
for c in &mut g.children {
@@ -368,4 +385,21 @@ mod tests {
assert!(g.get("smoothness").is_none());
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
}
#[test]
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
let spec = find("ec.transition.cardwipe").unwrap();
let mut next = 1;
for (old, self_) in [(1, true), (0, false)] {
let mut g = instantiate(spec, &mut Ids(&mut next), "Card Wipe", [100.0, 50.0]);
take_prop(&mut g, "backSelf");
let pr = g.get_mut("backLayer").unwrap();
pr.ui = ParamUi::Popup { options: vec!["None".into(), "Self".into()] };
pr.value = Value::Enum(old);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("backLayer").unwrap().value, Value::Layer(None));
assert_eq!(g.get("backLayer").unwrap().ui, ParamUi::Layer);
assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_));
}
}
}
+1 -1
View File
@@ -431,7 +431,7 @@ mod tests {
fn noise_types_differ_and_block_is_piecewise_constant() {
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]);
// Block noise: neighbours inside one 10 px cell share a value.
assert_eq!(a.get(1, 1), a.get(2, 2));
assert_eq!(a.get(4, 4), a.get(5, 5));
let others: Vec<Image> = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect();
assert_ne!(others[0], others[1]);
assert_ne!(others[1], others[2]);
+416 -65
View File
@@ -10,6 +10,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::card3d::{Lighting, Proj};
use crate::generate::value_noise;
use crate::sim::{Acc, Shape, Sprite, h, hs, pct, pt, raster, spec, splat};
use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul};
@@ -17,7 +18,7 @@ use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
// ------------------------------------------------------------------ 3D pieces
type M3 = [[f64; 3]; 3];
pub(crate) type M3 = [[f64; 3]; 3];
fn mat_mul(a: &M3, b: &M3) -> M3 {
std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
@@ -35,7 +36,7 @@ fn rot_xyz(x: f64, y: f64, z: f64) -> M3 {
}
/// Rotation by `angle` about unit `axis` (Rodrigues).
fn rot_axis(axis: [f64; 3], angle: f64) -> M3 {
pub(crate) fn rot_axis(axis: [f64; 3], angle: f64) -> M3 {
let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
if l < 1e-12 || angle == 0.0 {
return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
@@ -72,9 +73,15 @@ struct Cam {
d: f64,
}
impl Cam {
fn proj(self) -> Proj {
Proj::simple(self.c, self.d)
}
}
/// A planar textured piece ready to draw.
#[derive(Clone, Copy)]
struct Piece {
pub(crate) struct Piece {
/// Screen (buffer px, homogeneous) → piece-local (a, b, w).
inv: M3,
/// Rest centre of the piece in texture pixels.
@@ -84,34 +91,43 @@ struct Piece {
n: u8,
bbox: [f32; 4],
shade: f32,
alpha: f32,
/// Lighting: colour multiplier and additive specular highlight.
tint: [f32; 3],
spec: [f32; 3],
pub(crate) alpha: f32,
/// Flat straight colour instead of the texture.
flat: Option<[f32; 3]>,
pub(crate) flat: Option<[f32; 3]>,
/// Facing away from the camera (draw the back texture).
back: bool,
pub(crate) back: bool,
/// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically
/// (flipped about the X axis), 2 horizontally (about the Y axis).
pub(crate) back_mirror: u8,
depth: f64,
}
impl Piece {
/// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its
/// centre at 3D `pos` (buffer px; z away from the camera).
fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: Cam) -> Option<Piece> {
/// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`.
pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into<Proj>) -> Option<Piece> {
let cam: Proj = cam.into();
let n = poly.len().min(6);
if n < 3 {
return None;
}
let (cx, cy, d) = (cam.c[0], cam.c[1], cam.d);
let zc = pos[2];
let hm: M3 = [
[cx * r[2][0] + d * r[0][0], cx * r[2][1] + d * r[0][1], cx * (d + zc) + d * (pos[0] - cx)],
[cy * r[2][0] + d * r[1][0], cy * r[2][1] + d * r[1][1], cy * (d + zc) + d * (pos[1] - cy)],
[r[2][0], r[2][1], d + zc],
];
let m = &cam.m;
let row = |i: usize| -> [f64; 3] {
[
m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0],
m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1],
m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3],
]
};
let hm: M3 = [row(0), row(1), row(2)];
let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY];
for v in &poly[..n] {
let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64);
let w = hm[2][0] * a + hm[2][1] * b + hm[2][2];
if w < d * 0.05 {
if w < cam.near {
return None;
}
let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32;
@@ -126,8 +142,12 @@ impl Piece {
if area < 0.0 {
pp[..n].reverse();
}
// Front normal (0, 0, -1) rotated; facing the camera when its z is negative.
let nz = -r[2][2];
// The front faces -z at rest; it faces the camera when its normal points at the eye.
let eye = cam.eye();
let nrm = [-r[0][2], -r[1][2], -r[2][2]];
let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]];
let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2];
let depth = hm[2][2];
Some(Piece {
inv,
c0,
@@ -135,10 +155,13 @@ impl Piece {
n: n as u8,
bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0],
shade: 1.0,
tint: [1.0; 3],
spec: [0.0; 3],
alpha: 1.0,
flat: None,
back: nz > 0.0,
depth: zc,
back: facing < 0.0,
back_mirror: 0,
depth,
})
}
@@ -146,10 +169,23 @@ impl Piece {
fn facing(r: &M3) -> f32 {
r[2][2].abs() as f32
}
/// Light the piece (rotation `r`, centre `pos`) with Lighting / Material.
pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) {
let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye);
self.tint = t;
self.spec = s;
}
}
impl From<Cam> for Proj {
fn from(c: Cam) -> Proj {
c.proj()
}
}
/// Draw pieces (already sorted far → near) over `out`.
fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) {
pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) {
raster(
out,
pieces,
@@ -181,6 +217,11 @@ fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&I
Some(c) => (c, 1.0),
None => {
let tex = if q.back { back.unwrap_or(front) } else { front };
let (u, v) = match (q.back, q.back_mirror) {
(true, 1) => (u, 2.0 * q.c0[1] - v),
(true, 2) => (2.0 * q.c0[0] - u, v),
_ => (u, v),
};
unpremul(tex.sample_bilinear(u as f64, v as f64))
}
};
@@ -189,13 +230,14 @@ fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&I
return None;
}
let k = q.shade;
Some([c[0] * k * a, c[1] * k * a, c[2] * k * a, a])
let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]);
Some([o[0] * a, o[1] * a, o[2] * a, a])
},
Acc::Over,
);
}
fn sort_far_first(pieces: &mut [Piece]) {
pub(crate) fn sort_far_first(pieces: &mut [Piece]) {
pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth));
}
@@ -430,12 +472,9 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf {
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let (cw, ch) = (lw / cols as f64, lh / rows as f64);
let zpos = pr.f("cameraPosition/cameraZ").max(0.05);
let focal = pr.f("cameraPosition/focalLength").max(1.0);
let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: lw.max(lh) * s * zpos * focal / 140.0 };
let ambient = pr.f("lighting/ambientLight") as f32;
let diffuse = pr.f("material/diffuse") as f32;
let intensity = pr.f("lighting/lightIntensity") as f32;
let cam = crate::card3d::projection(ctx, &b);
let eye = cam.eye();
let light = Lighting::from(ctx, &b);
let props: Vec<(u32, f64, f64)> =
CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect();
let src = b.img.clone();
@@ -458,7 +497,7 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (tw, th) = ((cw * s) as f32, (ch * s) as f32);
let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]];
let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?;
pc.shade = ambient + diffuse * intensity * Piece::facing(&r);
pc.light(&light, &r, pos, eye);
Some(pc)
})
.collect();
@@ -471,7 +510,8 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ------------------------------------------------------------------ Shatter
const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles"];
/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map.
const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"];
/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`.
fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec<Vec<[f64; 2]>> {
@@ -570,12 +610,40 @@ fn drag_factors(k: f64, t: f64) -> (f64, f64) {
((1.0 - e) / k, t / k - (1.0 - e) / (k * k))
}
}
/// Shatter's Front / Side / Back Mode options.
const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"];
/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour.
struct Face {
mode: u32,
img: Option<Image>,
}
impl Face {
fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face {
let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode);
// A face layer of None is the effect's own layer.
let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true));
Face { mode, img }
}
}
/// Group key of a Custom Shatter Map colour (pieces of one colour break as one).
fn map_key(c: [f32; 4]) -> u32 {
let (c, a) = unpremul(c);
if a < 0.5 {
return u32::MAX;
}
let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15;
q(c[0]) << 8 | q(c[1]) << 4 | q(c[2])
}
fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let view = pr.e("view");
let render = pr.e("render");
let kind = pr.e("shape/pattern");
let custom = kind as usize == SHATTER_PATTERNS.len() - 1;
let reps = pr.f("shape/repetitions").clamp(1.0, 500.0);
let dir = pr.f("shape/direction").to_radians();
let origin = pr.v2("shape/origin");
@@ -591,15 +659,29 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let gravity = pr.f("physics/gravity");
let gdir = pr.f("physics/gravityDirection").to_radians();
let ginc = pr.f("physics/gravityInclination").to_radians();
let ambient = pr.f("lighting/ambientLight") as f32;
let intensity = pr.f("lighting/lightIntensity") as f32;
let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed;
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let t = ctx.time.max(0.0);
let unit = lw * 0.1;
let cell = lw / reps;
let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s };
let extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s;
let cam = crate::card3d::projection(ctx, &b);
let eye = cam.eye();
let light = Lighting::from(ctx, &b);
// Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold
// (Invert Gradient: as dark) break.
let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true));
let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0;
let invert_gradient = pr.b("gradient/invertGradient");
// Textures.
let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]);
let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let front = Face::from(ctx, &b, "textures/frontMode", "textures/frontLayer", 1);
let side = Face::from(ctx, &b, "textures/sideMode", "textures/sideLayer", 0);
let backf = Face::from(ctx, &b, "textures/backMode", "textures/backLayer", 1);
let custom_map = custom.then(|| layer_or_self(ctx, &b, "shape/customShatterMap", true, true));
let white_fixed = pr.b("shape/whiteTilesFixed");
let (sd, cd) = dir.sin_cos();
let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd];
// Pattern-space bounds of the layer rectangle.
@@ -609,21 +691,45 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
});
let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max));
let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max));
let cells = pattern_cells(kind, cell, px0, py0, px1, py1, seed);
// Custom Shatter Map: square cells grouped by the map's colour.
let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed);
let polys: Vec<Vec<[f64; 2]>> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect();
let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::<f64>() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::<f64>() / lp.len() as f64];
let keys: Vec<u32> = match &custom_map {
Some(m) => polys
.iter()
.map(|lp| {
let c = centre(lp);
map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1]))
})
.collect(),
None => (0..polys.len() as u32).collect(),
};
// Group centres (custom map) — each group moves as one rigid piece.
let mut groups: std::collections::HashMap<u32, ([f64; 2], f64)> = std::collections::HashMap::new();
for (lp, &key) in polys.iter().zip(&keys) {
let c = centre(lp);
let e = groups.entry(key).or_insert(([0.0; 2], 0.0));
e.0[0] += c[0];
e.0[1] += c[1];
e.1 += 1.0;
}
let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit];
let src = b.img.clone();
let mut pieces: Vec<Piece> = cells
let mut pieces: Vec<Piece> = polys
.par_iter()
.enumerate()
.filter_map(|(i, poly)| {
let i = i as u32;
let lp: Vec<[f64; 2]> = poly.iter().map(|&q| to_layer(q)).collect();
.zip(keys.par_iter())
.flat_map_iter(|(lp, &key)| {
let mut out: Vec<Piece> = vec![];
let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max));
let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max));
if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh {
return None;
if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX {
return out.into_iter();
}
let c = [lp.iter().map(|v| v[0]).sum::<f64>() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::<f64>() / lp.len() as f64];
let pc_c = centre(lp);
let (gsum, gn) = groups[&key];
let gc = [gsum[0] / gn, gsum[1] / gn];
let i = key;
// Broken when inside a force sphere (centred `depth` in front of the layer).
let mut v = [0.0f64; 3];
let mut broken = false;
@@ -632,7 +738,7 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
if rad <= 0.0 {
continue;
}
let dv = [c[0] - fp[0], c[1] - fp[1], fd * lw];
let dv = [gc[0] - fp[0], gc[1] - fp[1], fd * lw];
let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt();
if dist < rad {
broken = true;
@@ -642,19 +748,31 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
}
}
if (broken && render == 1) || (!broken && render == 2) {
return None;
if broken && let Some(g) = &gradient {
let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1]));
let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64;
if invert_gradient {
l = 1.0 - l;
}
broken = threshold > 0.0 && l >= 1.0 - threshold;
}
let mut pos = [c[0], c[1], 0.0];
if broken && custom && white_fixed && key == 0xfff {
broken = false;
}
if (broken && render == 1) || (!broken && render == 2) {
return out.into_iter();
}
let mut gpos = [gc[0], gc[1], 0.0];
let mut r = rot_axis([1.0, 0.0, 0.0], 0.0);
if broken && t > 0.0 {
let moving = broken && t > 0.0;
if moving {
let mass = 1.0 + mass_var * hs(i, 1, seed) as f64;
for q in 0..3 {
v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0;
}
let (f1, f2) = drag_factors(k, t);
for q in 0..3 {
pos[q] += v[q] * f1 + g3[q] * f2;
gpos[q] += v[q] * f1 + g3[q] * f2;
}
let axis = match tumble {
1 => [0.0, 0.0, 0.0],
@@ -670,18 +788,66 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0);
r = rot_axis(axis, omega * f1);
}
// The piece's centre: its offset from the group centre, rotated with the group.
let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0];
let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::<f64>());
let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect();
let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32];
let c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_c[1] * s + b.offset[1]) as f32];
let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s];
let mut pc = Piece::new(&tex, c0, &r, posb, cam)?;
pc.shade = ambient + (1.0 - ambient) * intensity * Piece::facing(&r);
Some(pc)
let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() };
pc.light(&light, &r, posb, eye);
pc.alpha = tex_opacity;
let face = if pc.back { &backf } else { &front };
if face.mode == 0 {
pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]);
} else if face.mode == 2 {
pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]);
}
// Extrusion: the sides of a moving piece, from its front outline back by the depth.
if moving && extrude > 0.0 {
let nb = [r[0][2], r[1][2], r[2][2]];
let n = tex.len();
for e in 0..n {
let (a, bv) = (tex[e], tex[(e + 1) % n]);
let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64];
let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt();
if len < 1e-6 {
continue;
}
let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len);
let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]];
let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]);
// Side centre in world: edge midpoint (world) plus half the depth backwards.
let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64];
let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5);
let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32);
let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5];
let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]];
if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) {
sp.light(&light, &rs, mw, eye);
sp.alpha = tex_opacity;
let colour = match (&side.img, side.mode) {
(Some(img), m) if m != 0 => {
let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64));
if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c }
}
_ => [tex_color[0], tex_color[1], tex_color[2]],
};
sp.flat = Some(colour);
out.push(sp);
}
}
}
out.push(pc);
out.into_iter()
})
.collect();
sort_far_first(&mut pieces);
let mut out = Image::new(src.width, src.height);
let front_img = front.img.as_ref().unwrap_or(&src);
let back_img = backf.img.as_ref().unwrap_or(front_img);
if view == 0 {
draw_pieces(&mut out, &pieces, &src, None);
draw_pieces(&mut out, &pieces, front_img, Some(back_img));
} else {
// Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces.
let animated = view == 2 || view == 4;
@@ -1277,13 +1443,9 @@ fn card_dance_params() -> Vec<crate::ParamSpec> {
v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2)));
v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2)));
}
v.extend([
p("cameraPosition/cameraZ", "Z Position", num(2.0), slider(0.05, 10.0, 0.5, 5.0, 2)),
p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)),
p("lighting/ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)),
]);
v.extend(crate::card3d::camera_params(2.0));
v.extend(crate::card3d::lighting_params(1.0, 0.25));
v.extend(crate::card3d::material_params(0.75));
v
}
@@ -1399,6 +1561,8 @@ pub fn specs() -> Vec<EffectSpec> {
p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)),
p("shape/direction", "Direction", num(0.0), ParamUi::Angle),
p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point),
p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer),
p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox),
p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)),
@@ -1416,10 +1580,24 @@ pub fn specs() -> Vec<EffectSpec> {
p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle),
p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)),
p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)),
p("lighting/ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox),
p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)),
p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)),
p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)),
p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)),
p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer),
p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer),
p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer),
]
.into_iter()
.chain(crate::card3d::camera_params(4.0))
.chain(crate::card3d::lighting_params(1.0, 0.25))
.chain(crate::card3d::material_params(0.75))
.chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))])
.collect(),
shatter,
),
spec(
@@ -1651,4 +1829,177 @@ mod tests {
};
assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0));
}
/// A host with a comp camera (identity, or tilted) and a first comp light.
struct Scene {
camera: Option<[[f64; 4]; 3]>,
light: Option<crate::CompLight>,
layer: Option<Image>,
}
impl crate::EffectHost for Scene {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
let img = self.layer.clone()?;
let size = [img.width as f64, img.height as f64];
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: self.camera, light: self.light })
}
}
fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image {
run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img
}
#[test]
fn card_dance_camera_systems() {
let src = ramp(40, 30);
// Camera Position: rotating the camera changes the view; zero rotation is the layer.
let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0);
assert!(sum_diff(&r, &src) > 10.0);
let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0);
let order2 = run(
"ec.sim.carddance",
&[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))],
src.clone(),
0.0,
);
assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters");
// X, Y Position moves the view: the layer shifts by the offset.
let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0);
assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10));
// Corner Pins: the layer's corners land on the pins.
let pins = [
("cameraSystem", Value::Enum(1)),
("cornerPins/upperLeftCorner", pt(10.0, 5.0)),
("cornerPins/upperRightCorner", pt(30.0, 5.0)),
("cornerPins/lowerLeftCorner", pt(10.0, 25.0)),
("cornerPins/lowerRightCorner", pt(30.0, 25.0)),
];
let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0);
assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad");
assert!(cp.get(20, 15)[3] > 0.99);
assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre");
// Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it.
let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None };
let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident);
assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src));
let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident };
let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half);
assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty");
}
#[test]
fn card_dance_lighting_and_material() {
let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]);
let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))];
let lit = |extra: &[(&str, Value)]| {
let mut v: Vec<(&str, Value)> = tilt.to_vec();
v.extend_from_slice(extra);
run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15)
};
let base = lit(&[]);
// Tilted cards facing away from a frontal light get darker than flat ones.
assert!(base[0] < 0.45, "{base:?}");
// Light colour tints; intensity brightens; ambient alone lights evenly.
let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]);
assert!(red[0] > red[1] + 0.05);
assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]);
let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]);
assert!((amb[0] - 0.5).abs() < 1e-3);
// A light placed to the side (Point Source) changes the shading of the tilted card.
let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]);
assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}");
// Specular adds a highlight on flat, frontally lit cards.
let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15);
assert!(flat[0] > 0.9, "{flat:?}");
// First Comp Light: no light in the comp → ambient only; a light from the host is used.
let none = Scene { camera: None, light: None, layer: None };
let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15);
assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}");
let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none };
let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15);
assert!(a[2] > a[0] + 0.2, "{a:?}");
}
#[test]
fn shatter_custom_map_gradient_textures_and_extrusion() {
let src = ramp(32, 32);
let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))];
let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| {
let mut v: Vec<(&str, Value)> = base.to_vec();
v.extend_from_slice(extra);
match host {
Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h),
None => run("ec.sim.shatter", &v, src.clone(), t),
}
};
// Custom Shatter Map: a map white on the left and black on the right; with White Tiles
// Fixed the left half stays put while the right half breaks away.
let mut map = Image::new(32, 32);
for y in 0..32 {
for x in 0..32 {
map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
}
}
let host = Scene { camera: None, light: None, layer: Some(map) };
let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))];
let at_rest = with(&custom, 0.0, Some(&host));
assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src));
let broke = with(&custom, 1.0, Some(&host));
assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed");
assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved");
// Gradient: a black gradient layer with threshold 50 % keeps everything together.
let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) };
let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black));
assert!(max_diff(&g, &src) < 0.05);
let gi = with(
&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))],
1.0,
Some(&black),
);
assert!(sum_diff(&gi, &src) > 10.0);
// Textures: Front Mode Color paints the pieces flat; Opacity fades them.
let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None);
let p = flat.get(16, 16);
assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}");
let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None);
assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4));
// Extrusion: thicker pieces cover more once they tumble.
let cover = |d: f64| {
with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None)
.data
.iter()
.map(|p| p[3])
.sum::<f32>()
};
assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0));
// Deterministic and seek-consistent.
assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host)));
}
fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range<u32>) -> f32 {
let mut m = 0.0f32;
for y in 0..a.height {
for x in xs.clone() {
let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64));
m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max);
}
}
m
}
fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range<u32>) -> f32 {
let mut s = 0.0;
for y in 0..a.height {
for x in xs.clone() {
let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64));
s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>();
}
}
s
}
}
+190 -70
View File
@@ -1,5 +1,5 @@
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
//!
//! Every transition is the identity at 0 % completion. Angles are clockwise from "up".
@@ -9,6 +9,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
use rayon::prelude::*;
use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first};
use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider};
@@ -163,21 +164,47 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Card Wipe: the layer is cut into Rows × Columns cards that flip over in Flip Order, each
/// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera
/// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards
/// vanish as they turn away). Position / Rotation Jitter wobble the cards over time.
fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = completion(ctx);
if done <= 0.0 {
return b;
let pr = ctx.params;
let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0);
let jitter_on = [
"positionJitter/xJitterAmount",
"positionJitter/yJitterAmount",
"positionJitter/zJitterAmount",
"rotationJitter/xRotJitterAmount",
"rotationJitter/yRotJitterAmount",
"rotationJitter/zRotJitterAmount",
]
.iter()
.any(|id| f(id) != 0.0);
if done <= 0.0
&& !jitter_on
&& pr.e("cameraSystem") == 0
&& f("cameraPosition/xRotation") == 0.0
&& f("cameraPosition/yRotation") == 0.0
&& f("cameraPosition/zRotation") == 0.0
{
// Nothing has moved: the layer as is (exactly).
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2());
if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) {
return b;
}
}
let tw = (ctx.params.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
let rows = ctx.params.f("rows").round().clamp(1.0, 1000.0) as i64;
let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
let rows = pr.f("rows").round().clamp(1.0, 1000.0) as i64;
// Rows & Columns: Independent, or Columns Follows Rows.
let cols = if ctx.params.e("rowsAndColumns") == 1 { rows } else { ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64 };
let card_scale = ctx.params.f("cardScale").max(0.01);
let axis = ctx.params.e("flipAxis");
let order = ctx.params.e("flipOrder");
let back_self = ctx.params.e("backLayer") == 1;
let jitter = ctx.params.f("timingRandomness").clamp(0.0, 1.0);
let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d;
let cols = if pr.e("rowsAndColumns") == 1 { rows } else { pr.f("columns").round().clamp(1.0, 1000.0) as i64 };
let card_scale = pr.f("cardScale").max(0.01);
let axis = pr.e("flipAxis");
let direction = pr.e("flipDirection");
let order = pr.e("flipOrder");
let jitter = pr.f("timingRandomness").clamp(0.0, 1.0);
let seed = pr.f("randomSeed") as u32 ^ 0xca7d;
let (x0, y0, w, h) = layer_rect(ctx, &b);
let (cw, ch) = (w / cols as f64, h / rows as f64);
let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 };
@@ -187,64 +214,91 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
Some(o) => place_layer(ctx, &b, &o, 2),
None => b.img.clone(),
});
let src = b.img.clone();
b.img = remap(&src, false, |x, y| {
let (lx, ly) = (x - x0, y - y0);
if lx < 0.0 || ly < 0.0 || lx >= w || ly >= h {
return Some((x, y));
}
let i = ((lx / cw).floor() as i64).min(cols - 1);
let j = ((ly / ch).floor() as i64).min(rows - 1);
let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
let mut o = match order {
1 => 1.0 - nx(i),
2 => ny(j),
3 => 1.0 - ny(j),
4 => (nx(i) + ny(j)) * 0.5,
5 => (1.0 - nx(i) + ny(j)) * 0.5,
6 => (nx(i) + 1.0 - ny(j)) * 0.5,
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
8 => {
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
let (c, _) = unpremul(g);
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
// Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the
// old "Self" choice; none hides the cards' backs.
let back = match ctx.layer_param("backLayer", true) {
Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)),
None if pr.b("backSelf") => Some(b.img.clone()),
None => None,
};
let cam = crate::card3d::projection(ctx, &b);
let eye = cam.eye();
let light = crate::card3d::Lighting::from(ctx, &b);
let t = ctx.time;
let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 {
let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU;
let rate = 0.5 + hash1(i as u32, j as u32, seed ^ (k + 101)) as f64;
(t * speed * rate * std::f64::consts::TAU + ph).sin()
};
let (jx, jy, jz, js) =
(f("positionJitter/xJitterAmount"), f("positionJitter/yJitterAmount"), f("positionJitter/zJitterAmount"), f("positionJitter/jitterSpeed"));
let (rjx, rjy, rjz, rjs) =
(f("rotationJitter/xRotJitterAmount"), f("rotationJitter/yRotJitterAmount"), f("rotationJitter/zRotJitterAmount"), f("rotationJitter/rotJitterSpeed"));
let mut pieces: Vec<Piece> = (0..rows * cols)
.into_par_iter()
.filter_map(|k| {
let (i, j) = (k % cols, k / cols);
let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
let mut o = match order {
1 => 1.0 - nx(i),
2 => ny(j),
3 => 1.0 - ny(j),
4 => (nx(i) + ny(j)) * 0.5,
5 => (1.0 - nx(i) + ny(j)) * 0.5,
6 => (nx(i) + 1.0 - ny(j)) * 0.5,
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
8 => {
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
let (c, _) = unpremul(g);
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
}
_ => nx(i),
};
if jitter > 0.0 {
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
}
_ => nx(i),
};
if jitter > 0.0 {
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
}
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
let (mut dx, mut dy) = ((x - ccx) / card_scale, (y - ccy) / card_scale);
if dx.abs() > cw * 0.5 + 1e-9 || dy.abs() > ch * 0.5 + 1e-9 {
return None;
}
if p <= 0.0 {
return Some((ccx + dx, ccy + dy));
}
let cos = (p * std::f64::consts::PI).cos();
if cos.abs() < 1e-4 || (cos < 0.0 && !back_self) {
return None;
}
let around_x = match axis {
1 => false,
2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5,
_ => true,
};
if around_x {
dy /= cos;
if dy.abs() > ch * 0.5 {
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
let around_x = match axis {
1 => false,
2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5,
_ => true,
};
let sign = match direction {
1 => -1.0,
2 if hash1(i as u32, j as u32, seed ^ 0x99) < 0.5 => -1.0,
_ => 1.0,
};
let a = p * std::f64::consts::PI * sign;
let flip = if around_x { rot_axis([1.0, 0.0, 0.0], a) } else { rot_axis([0.0, 1.0, 0.0], a) };
let jr = crate::card3d::rot_ordered(
(rjx * jit(i, j, 11, rjs)).to_radians(),
(rjy * jit(i, j, 12, rjs)).to_radians(),
(rjz * jit(i, j, 13, rjs)).to_radians(),
0,
);
let r = crate::card3d::mat_mul(&jr, &flip);
let pos = [ccx + jx * jit(i, j, 21, js) * cw, ccy + jy * jit(i, j, 22, js) * ch, jz * jit(i, j, 23, js) * cw.max(ch)];
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
let (sw, sh) = ((cw * 0.5 * card_scale.min(1.0)) as f32, (ch * 0.5 * card_scale.min(1.0)) as f32);
let (cx, cy) = (ccx as f32, ccy as f32);
let poly = [[cx - sw, cy - sh], [cx + sw, cy - sh], [cx + sw, cy + sh], [cx - sw, cy + sh]];
// Above 1 the card's picture is magnified (cropped to the card).
let k = card_scale.max(1.0);
let rs: crate::card3d::M3 = std::array::from_fn(|q| [r[q][0] * k, r[q][1] * k, r[q][2]]);
let poly = poly.map(|v| [cx + (v[0] - cx) / k as f32, cy + (v[1] - cy) / k as f32]);
let mut pc = Piece::new(&poly, [cx, cy], &rs, pos, cam)?;
if pc.back && back.is_none() {
return None;
}
} else {
dx /= cos;
if dx.abs() > cw * 0.5 {
return None;
}
}
Some((ccx + dx, ccy + dy))
});
pc.back_mirror = if around_x { 1 } else { 2 };
pc.light(&light, &r, pos, eye);
Some(pc)
})
.collect();
sort_far_first(&mut pieces);
let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height);
draw_pieces(&mut out, &pieces, &b.img, back.as_ref());
b.img = out;
b
}
@@ -447,12 +501,15 @@ pub fn specs() -> Vec<EffectSpec> {
vec![
done(),
p("transitionWidth", "Transition Width", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
p("backLayer", "Back Layer", Value::Enum(0), popup(&["None", "Self"])),
p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer),
// Projects saved when Back Layer was a None / Self popup: Self.
p("backSelf", "Back Layer Is Self", Value::Bool(false), ParamUi::Hidden),
p("rowsAndColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])),
p("rows", "Rows", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
p("columns", "Columns", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
p("cardScale", "Card Scale", num(1.0), slider(0.01, 10.0, 0.01, 2.0, 2)),
p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])),
p("flipDirection", "Flip Direction", Value::Enum(0), popup(&["Positive", "Negative", "Random"])),
p(
"flipOrder",
"Flip Order",
@@ -472,7 +529,22 @@ pub fn specs() -> Vec<EffectSpec> {
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
p("timingRandomness", "Timing Randomness", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
],
]
.into_iter()
.chain(crate::card3d::camera_params(2.0))
.chain(crate::card3d::lighting_params(1.0, 0.25))
.chain(crate::card3d::material_params(0.75))
.chain([
p("positionJitter/xJitterAmount", "X Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("positionJitter/jitterSpeed", "Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("positionJitter/yJitterAmount", "Y Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("positionJitter/zJitterAmount", "Z Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("rotationJitter/xRotJitterAmount", "X Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
p("rotationJitter/rotJitterSpeed", "Rot Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("rotationJitter/yRotJitterAmount", "Y Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
p("rotationJitter/zRotJitterAmount", "Z Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
])
.collect(),
card_wipe,
),
spec(
@@ -635,7 +707,7 @@ mod tests {
let back = run(
"ec.transition.cardwipe",
&img,
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))],
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backSelf", Value::Bool(true))],
);
assert_eq!(back.get(2, 20)[3], 1.0);
}
@@ -746,4 +818,52 @@ mod tests {
assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first");
assert_eq!(out.get(1, 1)[3], 1.0, "corners last");
}
fn run_t(img: &Image, set: &[(&str, Value)], time: f64, host: Option<&dyn crate::EffectHost>) -> Image {
let s = find("ec.transition.cardwipe").unwrap();
let size = [img.width as f64, img.height as f64];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, size))).collect() };
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let env = crate::EffectEnv { host, ..Default::default() };
let ctx = EffectCtx { params: &params, time, layer_size: size, seed: 7, adjustment: false, env };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
}
#[test]
fn card_wipe_back_layer_direction_jitter_and_camera() {
struct Green;
impl crate::EffectHost for Green {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
Some(crate::LayerPixels { buf: Buf { img: Image::filled(80, 40, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [80.0, 40.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]);
let v = [("completion", num(100.0)), ("rows", num(1.0)), ("columns", num(8.0))];
// Fully flipped cards show the Back Layer.
let mut b: Vec<(&str, Value)> = v.to_vec();
b.push(("backLayer", Value::Layer(Some(3))));
let out = run_t(&img, &b, 0.0, Some(&Green));
let p = out.get(45, 20);
assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}");
// Half way, Flip Direction decides which way the cards turn (the views differ).
let half = [("completion", num(30.0)), ("rows", num(1.0)), ("columns", num(1.0)), ("transitionWidth", num(100.0)), ("flipAxis", Value::Enum(1))];
let pos = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0))]].concat(), 0.0, None);
let neg = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0)), ("flipDirection", Value::Enum(1))]].concat(), 0.0, None);
assert_ne!(pos, neg);
// Position Jitter moves the cards over time (and only then).
let j = [("completion", num(0.0)), ("positionJitter/xJitterAmount", num(0.3))];
let a = run_t(&img, &j, 0.0, None);
let c = run_t(&img, &j, 0.37, None);
assert_ne!(a, c);
assert_eq!(c, run_t(&img, &j, 0.37, None), "deterministic");
assert_eq!(run_t(&img, &[("completion", num(0.0))], 0.37, None), img, "no jitter, no change");
// Camera Position rotation tilts the whole wipe in perspective.
let tilt = run_t(&img, &[("completion", num(0.0)), ("cameraPosition/yRotation", num(40.0))], 0.0, None);
assert_eq!(tilt.get(0, 0)[3], 0.0);
}
}
+25
View File
@@ -151,6 +151,31 @@ 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 })
}
fn layer_at(&self, id: u64, comp_time: f64, 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 {
+13
View File
@@ -163,6 +163,19 @@ fn effects_run_in_pipeline() {
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
#[test]
fn card_dance_comp_camera_matches_the_default_view() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50);
add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
+5 -5
View File
@@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 303 effects with 2562 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 303 effects with 2647 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**: 266 implemented in full, 37 partial (what is missing is listed).
- **Status**: 269 implemented in full, 34 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`).
@@ -328,11 +328,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented |
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented |
| CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented |
| Card Dance | `ec.sim.carddance` | 35 | | 32 | Partial: no camera system, corner pins or lighting/material controls |
| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented |
| Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type |
| Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map |
| Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles |
| Shatter | `ec.sim.shatter` | 26 | | 32 | Partial: no custom shatter map, gradient, textures or extrusion rendering |
| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented |
| Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group |
## Stylize
@@ -400,7 +400,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented |
| 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 |
| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented |