mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
Merge main into M12.7 (environment background layers keep their 3D runs on the CPU path)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
//! pixels to their parent). Without a working space the sRGB curve is used.
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::ProjectSettings;
|
||||
use effectcraft_project::{HdrMode, ProjectSettings};
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -32,12 +32,24 @@ pub struct Pipe {
|
||||
pub linear: bool,
|
||||
/// Blend in linear light (and the working space is not already linear).
|
||||
pub linear_blend: bool,
|
||||
/// The top-level output's space (sRGB unless Project Settings choose another).
|
||||
pub out: ColorSpace,
|
||||
/// HDR handling for standard-dynamic-range output.
|
||||
pub hdr: HdrMode,
|
||||
}
|
||||
|
||||
impl Pipe {
|
||||
pub fn of(s: &ProjectSettings) -> Pipe {
|
||||
let linear = s.linearize && s.working_space.is_some();
|
||||
Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear }
|
||||
// ACES working spaces are linear light by definition.
|
||||
let linear = s.working_space.is_some_and(|w| s.linearize || w.is_linear());
|
||||
Pipe {
|
||||
levels: s.bit_depth.levels(),
|
||||
space: s.working_space,
|
||||
linear,
|
||||
linear_blend: s.blend_linear && !linear,
|
||||
out: s.output_space.unwrap_or(ColorSpace::Srgb),
|
||||
hdr: s.hdr,
|
||||
}
|
||||
}
|
||||
|
||||
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
|
||||
@@ -67,10 +79,22 @@ impl Pipe {
|
||||
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Working space → sRGB display (top-level comp output).
|
||||
/// Working space → the output space (top-level comp output; sRGB by default). `None` when
|
||||
/// unmanaged or when [`Pipe::output_hdr`] applies.
|
||||
pub fn output(&self) -> Option<Conversion> {
|
||||
let ws = self.space?;
|
||||
Conversion::new(ws, self.linear, ColorSpace::Srgb, false)
|
||||
if self.output_hdr().is_some() {
|
||||
return None;
|
||||
}
|
||||
Conversion::new(ws, self.linear, self.out, false)
|
||||
}
|
||||
|
||||
/// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working
|
||||
/// space → linear output primaries, the HDR curve, then the output's encoding.
|
||||
pub fn output_hdr(&self) -> Option<(Option<Conversion>, HdrMode, Conversion)> {
|
||||
let ws = self.space?;
|
||||
(self.hdr != HdrMode::Clip && !self.out.is_hdr())
|
||||
.then(|| (Conversion::new(ws, self.linear, self.out, true), self.hdr, Conversion::delinearize(self.out)))
|
||||
}
|
||||
|
||||
/// One pixel clamped and quantised to the bit depth.
|
||||
@@ -122,6 +146,7 @@ impl Pipe {
|
||||
let mut k = self.levels.map_or(0, |l| l as u64);
|
||||
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
|
||||
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
|
||||
k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64);
|
||||
k
|
||||
}
|
||||
}
|
||||
@@ -202,3 +227,42 @@ pub fn convert(img: &mut Image, c: &Conversion) {
|
||||
*p = [o[0] * a, o[1] * a, o[2] * a, a];
|
||||
});
|
||||
}
|
||||
|
||||
/// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel).
|
||||
pub fn compand(v: f32) -> f32 {
|
||||
const K: f32 = 0.8;
|
||||
if v <= K { v } else { K + (1.0 - K) * (1.0 - (-(v - K) / (1.0 - K)).exp()) }
|
||||
}
|
||||
|
||||
/// Extended Reinhard tone mapping of luminance with white at `W` (linear light).
|
||||
pub fn tone_map(c: [f32; 3]) -> [f32; 3] {
|
||||
const W: f32 = 4.0;
|
||||
let l = effectcraft_color::luminance(c[0], c[1], c[2]);
|
||||
if l <= 0.0 {
|
||||
return c;
|
||||
}
|
||||
let lm = l * (1.0 + l / (W * W)) / (1.0 + l);
|
||||
let k = lm / l;
|
||||
c.map(|v| v * k)
|
||||
}
|
||||
|
||||
/// The HDR step of [`Pipe::output_hdr`] on premultiplied pixels.
|
||||
pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode, encode: Conversion) {
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
let a = p[3];
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut c = [p[0] / a, p[1] / a, p[2] / a];
|
||||
if let Some(t) = &to_linear {
|
||||
c = t.apply(c);
|
||||
}
|
||||
c = match mode {
|
||||
HdrMode::Clip => c,
|
||||
HdrMode::Compand => c.map(compand),
|
||||
HdrMode::ToneMap => tone_map(c),
|
||||
};
|
||||
let o = encode.apply(c);
|
||||
*p = [o[0] * a, o[1] * a, o[2] * a, a];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -591,6 +591,12 @@ impl<'a> Renderer<'a> {
|
||||
color::convert(&mut canvas, &c);
|
||||
self.pipe.quantize(&mut canvas);
|
||||
}
|
||||
if self.depth == 0
|
||||
&& let Some((lin, mode, enc)) = self.pipe.output_hdr()
|
||||
{
|
||||
color::output_hdr(&mut canvas, lin, mode, enc);
|
||||
self.pipe.quantize(&mut canvas);
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
@@ -1066,7 +1072,7 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
|
||||
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
|
||||
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
let r = self.opts.roi.filter(|_| self.depth == 0)?;
|
||||
let s = self.opts.scale;
|
||||
if r[2] <= 0.0 || r[3] <= 0.0 {
|
||||
@@ -1306,17 +1312,29 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
|
||||
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) {
|
||||
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, iso: Image, canvas: &mut Image, opacity: f32) {
|
||||
self.composite_iso_with(ctx, layer, iso, canvas, opacity, None);
|
||||
}
|
||||
|
||||
/// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D
|
||||
/// matte seen through the camera); otherwise the matte layer is placed in 2D.
|
||||
pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option<Image>) {
|
||||
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
|
||||
let preserve = layer.preserve_transparency;
|
||||
if let Some((m, kind)) = matte {
|
||||
let mut mimg = Image::new(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
&& let Some(mb) = self.layer_buf(ctx, m)
|
||||
{
|
||||
let mo = ctx.opacity(m) as f32;
|
||||
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
|
||||
}
|
||||
let mimg = match matte_img {
|
||||
Some(i) if i.width == canvas.width && i.height == canvas.height => i,
|
||||
_ => {
|
||||
let mut mimg = Image::new(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
&& let Some(mb) = self.layer_buf(ctx, m)
|
||||
{
|
||||
let mo = ctx.opacity(m) as f32;
|
||||
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
|
||||
}
|
||||
mimg
|
||||
}
|
||||
};
|
||||
iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
|
||||
let k = match kind {
|
||||
MatteKind::Alpha => q[3],
|
||||
|
||||
@@ -140,25 +140,135 @@ pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) ->
|
||||
scene::build(r, ctx, run, out)
|
||||
}
|
||||
|
||||
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
|
||||
if s.indices.is_empty() {
|
||||
return;
|
||||
/// Rasterise one scene (GPU when available, else CPU) and resolve it.
|
||||
fn raster_resolved(r: &Renderer, s: &Scene) -> (Image, Vec<f32>) {
|
||||
let target = r.active_accel().and_then(|a| a.raster_3d(s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(s));
|
||||
resolve(&target, s.ssaa.max(1))
|
||||
}
|
||||
|
||||
/// Number of motion-blur sub-samples for a set of layers (1 = none blurred).
|
||||
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize {
|
||||
if !layers.iter().any(|l| scene::motion_blurred(r, ctx, l)) {
|
||||
return 1;
|
||||
}
|
||||
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
|
||||
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
|
||||
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
|
||||
}
|
||||
|
||||
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
|
||||
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
|
||||
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
|
||||
/// depth is the nearest over the samples.
|
||||
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
|
||||
let n = mb_samples(r, ctx, layers);
|
||||
let (mut img, depth, linear) = if n <= 1 {
|
||||
let s = scene::build(r, ctx, layers, out);
|
||||
if s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (img, depth) = raster_resolved(r, &s);
|
||||
(img, depth, s.linear_io)
|
||||
} else {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let mut acc = Image::new(out.0, out.1);
|
||||
let mut depth = vec![f32::INFINITY; (out.0 * out.1) as usize];
|
||||
let mut linear = false;
|
||||
let mut any = false;
|
||||
let k = 1.0 / n as f32;
|
||||
for i in 0..n {
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
|
||||
let s = scene::build_at(r, ctx, Some(&sub), layers, out);
|
||||
linear = s.linear_io;
|
||||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
let (img, d) = raster_resolved(r, &s);
|
||||
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, p)| {
|
||||
for c in 0..4 {
|
||||
a[c] += p[c] * k;
|
||||
}
|
||||
});
|
||||
depth.par_iter_mut().zip(d.par_iter()).for_each(|(a, b)| *a = a.min(*b));
|
||||
}
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
(acc, depth, linear)
|
||||
};
|
||||
let cam = camera_for(r, ctx);
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
|
||||
}
|
||||
if !s.linear_io {
|
||||
if !linear {
|
||||
encode(&mut img);
|
||||
}
|
||||
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
|
||||
let k = 1.0 - s[3];
|
||||
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
|
||||
});
|
||||
Some((img, depth))
|
||||
}
|
||||
|
||||
/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path.
|
||||
fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool {
|
||||
l.blend_mode != effectcraft_color::BlendMode::Normal
|
||||
|| l.preserve_transparency
|
||||
|| l.track_matte.is_some_and(|tm| tm.layer != l.id && ctx.comp.layer(tm.layer).is_some())
|
||||
}
|
||||
|
||||
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
|
||||
///
|
||||
/// Layers with a blend mode, a track matte or Preserve Transparency can't blend inside the
|
||||
/// depth-buffered scene: they are rendered on their own (lit by the same lights, through the
|
||||
/// same camera), hidden where the rest of the run is nearer, then composited through the 2D
|
||||
/// path (matte, Preserve Transparency, blend mode) from the farthest to the nearest.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
let out = (canvas.width, canvas.height);
|
||||
let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l));
|
||||
let main = render_layers(r, ctx, &plain, out);
|
||||
if let Some((img, _)) = &main {
|
||||
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
|
||||
let k = 1.0 - s[3];
|
||||
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
|
||||
});
|
||||
}
|
||||
if special.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cam = camera_for(r, ctx);
|
||||
let mut order: Vec<(f64, &Layer)> = special
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3]));
|
||||
(cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l)
|
||||
})
|
||||
.collect();
|
||||
order.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
for (_, l) in order {
|
||||
let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue };
|
||||
if let Some((m, md)) = &main {
|
||||
iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| {
|
||||
if *mz < *z - 0.01 * z.abs().max(1.0) {
|
||||
let k = 1.0 - q[3].clamp(0.0, 1.0);
|
||||
for c in p.iter_mut() {
|
||||
*c *= k;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// A 3D matte layer is drawn through the camera too.
|
||||
let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| {
|
||||
let mut solo = m.clone();
|
||||
solo.track_matte = None;
|
||||
solo.blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
solo.preserve_transparency = false;
|
||||
solo.switches.video = true;
|
||||
if m.is_active_at(ctx.time) {
|
||||
render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1))
|
||||
} else {
|
||||
Image::new(out.0, out.1)
|
||||
}
|
||||
});
|
||||
r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -806,6 +806,20 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
|
||||
|
||||
/// Build the scene of a run of 3D layers.
|
||||
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
|
||||
build_at(r, ctx, None, run, out)
|
||||
}
|
||||
|
||||
/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option).
|
||||
pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||||
r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
|
||||
}
|
||||
|
||||
/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights
|
||||
/// are evaluated at `sub`'s time; the other layers stay at `ctx`'s.
|
||||
pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: &[&Layer], out: (u32, u32)) -> Scene {
|
||||
let base = ctx;
|
||||
let at = sub.unwrap_or(ctx);
|
||||
let ctx = at;
|
||||
let cam = camera_for(r, ctx);
|
||||
let lin = r.pipe.linear || r.pipe.linear_blend;
|
||||
let ssaa = ssaa_for(r, out);
|
||||
@@ -816,6 +830,7 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)
|
||||
cam,
|
||||
};
|
||||
for layer in run {
|
||||
let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base };
|
||||
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -312,3 +312,116 @@ fn extruded_text_renders_as_a_mesh() {
|
||||
let img = render(&p, cid);
|
||||
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion blur, blend modes, mattes
|
||||
|
||||
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
|
||||
p.add_item("S", Default::default(), None, ItemKind::Solid(effectcraft_project::Solid { color, width: w, height: h, pixel_aspect: 1.0 }))
|
||||
}
|
||||
|
||||
/// Pixels of row `y` whose alpha is partial (motion-blur smear or anti-aliasing).
|
||||
fn partial(img: &crate::Image, y: u32) -> usize {
|
||||
(0..img.width).filter(|x| (0.05..0.95).contains(&img.data[(y * img.width + x) as usize][3])).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_blur_smears_moving_meshes() {
|
||||
let (mut p, cid) = project(160, 120);
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/depth", Value::Scalar(30.0));
|
||||
// 60 px per frame to the right.
|
||||
let pr = l.props.prop_mut("transform/position").unwrap();
|
||||
pr.keys = vec![
|
||||
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 60.0, 0.0])),
|
||||
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0 + 30.0 * 60.0, 60.0, 0.0])),
|
||||
];
|
||||
});
|
||||
let sharp = render(&p, cid);
|
||||
let hard = partial(&sharp, 60);
|
||||
assert!(hard <= 4, "no blur without the switch: {hard}");
|
||||
p.comp_mut(cid).unwrap().layers[0].switches.motion_blur = true;
|
||||
let blurred = render(&p, cid);
|
||||
let smear = partial(&blurred, 60);
|
||||
// A 180° shutter smears over half a frame of motion (≈ 30 px).
|
||||
assert!(smear > 20, "smeared: {smear}");
|
||||
// Coverage is conserved: the summed alpha along the row stays about the cube's width.
|
||||
let sum = |im: &crate::Image| (0..160).map(|x| im.data[60 * 160 + x][3]).sum::<f32>();
|
||||
assert!((sum(&blurred) - sum(&sharp)).abs() < 4.0, "{} vs {}", sum(&blurred), sum(&sharp));
|
||||
// The comp's motion blur switch and the render option turn it off.
|
||||
p.comp_mut(cid).unwrap().enable_motion_blur = false;
|
||||
assert!(partial(&render(&p, cid), 60) <= 4);
|
||||
p.comp_mut(cid).unwrap().enable_motion_blur = true;
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
assert!(partial(&r.comp_frame(cid, Tick::ZERO), 60) <= 4);
|
||||
// Shutter angle 0 has no exposure time: no smear.
|
||||
p.comp_mut(cid).unwrap().shutter_angle = 0.0;
|
||||
assert!(partial(&render(&p, cid), 60) <= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_and_occlusion_in_advanced_3d() {
|
||||
let (mut p, cid) = project(120, 90);
|
||||
let red = solid(&mut p, [1.0, 0.0, 0.0], 120, 90);
|
||||
let gray = solid(&mut p, [0.5, 0.5, 0.5], 60, 40);
|
||||
let blue = solid(&mut p, [0.0, 0.0, 1.0], 20, 20);
|
||||
// Back: a red card; middle: a gray Multiply card; front: a small blue card on the left.
|
||||
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Solid { item: gray }, |l| {
|
||||
l.switches.three_d = true;
|
||||
l.blend_mode = effectcraft_color::BlendMode::Multiply;
|
||||
set(l, "transform/position", Value::Vec3([60.0, 45.0, -20.0]));
|
||||
set(l, "transform/anchor", Value::Vec3([30.0, 20.0, 0.0]));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Solid { item: blue }, |l| {
|
||||
l.switches.three_d = true;
|
||||
set(l, "transform/position", Value::Vec3([45.0, 45.0, -40.0]));
|
||||
set(l, "transform/anchor", Value::Vec3([10.0, 10.0, 0.0]));
|
||||
});
|
||||
let img = render(&p, cid);
|
||||
let at = |x: usize, y: usize| img.data[y * 120 + x];
|
||||
// Multiply over red: half red, no gray.
|
||||
let c = at(70, 45);
|
||||
assert!((c[0] - 0.5).abs() < 0.02 && c[1] < 0.02 && c[2] < 0.02 && c[3] > 0.99, "multiplied {c:?}");
|
||||
// Outside the gray card: plain red.
|
||||
let r = at(115, 5);
|
||||
assert!(r[0] > 0.98 && r[1] < 0.02, "{r:?}");
|
||||
// The nearer blue card hides the Multiply card behind it.
|
||||
let b = at(40, 45);
|
||||
assert!(b[2] > 0.98 && b[0] < 0.02, "occluded {b:?}");
|
||||
// Normal mode for comparison: gray covers the red.
|
||||
p.comp_mut(cid).unwrap().layers[1].blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
let n = render(&p, cid).data[45 * 120 + 70];
|
||||
assert!((n[0] - 0.5).abs() < 0.02 && (n[1] - 0.5).abs() < 0.02, "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_mattes_in_advanced_3d() {
|
||||
let (mut p, cid) = project(120, 90);
|
||||
let white = solid(&mut p, [1.0, 1.0, 1.0], 120, 90);
|
||||
let small = solid(&mut p, [1.0, 1.0, 1.0], 40, 30);
|
||||
// Layer order (top first): the matte, then the matted layer.
|
||||
add(&mut p, cid, LayerSource::Solid { item: white }, |l| l.switches.three_d = true);
|
||||
add(&mut p, cid, LayerSource::Solid { item: small }, |l| {
|
||||
l.switches.three_d = true;
|
||||
l.switches.video = false;
|
||||
// A 3D matte is seen through the camera.
|
||||
set(l, "transform/position", Value::Vec3([30.0, 30.0, 0.0]));
|
||||
set(l, "transform/anchor", Value::Vec3([20.0, 15.0, 0.0]));
|
||||
});
|
||||
let matte = p.comp(cid).unwrap().layers[0].id;
|
||||
p.comp_mut(cid).unwrap().layers[1].track_matte = Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::Alpha });
|
||||
let img = render(&p, cid);
|
||||
let inside = img.data[30 * 120 + 30];
|
||||
let outside = img.data[70 * 120 + 100];
|
||||
assert!(inside[3] > 0.99 && inside[0] > 0.99, "{inside:?}");
|
||||
assert!(outside[3] < 0.01, "{outside:?}");
|
||||
// An inverted alpha matte keeps the outside instead.
|
||||
p.comp_mut(cid).unwrap().layers[1].track_matte =
|
||||
Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::AlphaInverted });
|
||||
let img = render(&p, cid);
|
||||
assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99);
|
||||
}
|
||||
|
||||
@@ -340,11 +340,15 @@ pub enum View3D {
|
||||
Custom1,
|
||||
Custom2,
|
||||
Custom3,
|
||||
/// The comp's default camera (50 mm, looking at the comp centre), ignoring camera layers;
|
||||
/// orbitable like the custom views.
|
||||
Default,
|
||||
}
|
||||
|
||||
impl View3D {
|
||||
pub const ALL: [View3D; 10] = [
|
||||
pub const ALL: [View3D; 11] = [
|
||||
View3D::ActiveCamera,
|
||||
View3D::Default,
|
||||
View3D::Front,
|
||||
View3D::Left,
|
||||
View3D::Top,
|
||||
@@ -367,6 +371,7 @@ impl View3D {
|
||||
View3D::Custom1 => "Custom View 1",
|
||||
View3D::Custom2 => "Custom View 2",
|
||||
View3D::Custom3 => "Custom View 3",
|
||||
View3D::Default => "Default",
|
||||
}
|
||||
}
|
||||
/// Stable id used by commands (`activeCamera`, `front`, `custom1`…).
|
||||
@@ -382,6 +387,7 @@ impl View3D {
|
||||
View3D::Custom1 => "custom1",
|
||||
View3D::Custom2 => "custom2",
|
||||
View3D::Custom3 => "custom3",
|
||||
View3D::Default => "default",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<View3D> {
|
||||
@@ -433,6 +439,7 @@ pub fn default_view_cam(v: View3D, w: f64, h: f64) -> ViewCam {
|
||||
View3D::Custom1 => custom(30.0, -20.0),
|
||||
View3D::Custom2 => custom(0.0, -35.0),
|
||||
View3D::Custom3 => custom(-30.0, -20.0),
|
||||
View3D::Default => ViewCam { eye: arr(vec3(w / 2.0, h / 2.0, -zoom)), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,15 @@ fn scene_lights(ctx: &EvalCtx) -> Vec<LightState> {
|
||||
|
||||
/// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
// Environment background layers: an infinitely distant sky behind the rest of the run.
|
||||
if run.iter().any(|l| l.environment_background) {
|
||||
for l in run.iter().filter(|l| l.environment_background) {
|
||||
draw_sky(r, ctx, l, canvas);
|
||||
}
|
||||
let rest: Vec<&Layer> = run.iter().copied().filter(|l| !l.environment_background).collect();
|
||||
draw_run(r, ctx, &rest, canvas);
|
||||
return;
|
||||
}
|
||||
// 3D adjustment layers act on everything below them in the stack (like 2D ones): split.
|
||||
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
|
||||
draw_run(r, ctx, &run[..k], canvas);
|
||||
@@ -671,9 +680,10 @@ pub struct Plane3d {
|
||||
}
|
||||
|
||||
/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU
|
||||
/// (adjustment or wireframe layers, Advanced 3D).
|
||||
/// (adjustment, wireframe or environment background layers, Advanced 3D).
|
||||
pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option<Run3d> {
|
||||
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.switches.quality == effectcraft_project::Quality::Wireframe) {
|
||||
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || l.switches.quality == effectcraft_project::Quality::Wireframe)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
|
||||
@@ -883,3 +893,49 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
|
||||
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
|
||||
aux
|
||||
}
|
||||
|
||||
/// Rotation (radians) of the comp's first Environment light, which also turns its backdrop.
|
||||
pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
|
||||
ctx.comp
|
||||
.layers
|
||||
.iter()
|
||||
.find(|l| matches!(l.source, effectcraft_project::LayerSource::Light { kind: LightKind::Environment }) && l.is_active_at(ctx.time))
|
||||
.and_then(|l| l.props.sub("lightOptions").map(|g| ctx.f(l, g, "rotation", 0.0)))
|
||||
.unwrap_or(0.0)
|
||||
.to_radians() as f32
|
||||
}
|
||||
|
||||
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
|
||||
/// pixel's view direction through the camera, as an infinitely distant backdrop.
|
||||
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return };
|
||||
let img = &buf.img;
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return;
|
||||
}
|
||||
let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
|
||||
if op <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let cam = camera_for(r, ctx);
|
||||
let (fwd, right, down) = (cam.forward(), cam.right(), cam.down());
|
||||
let s = r.opts.scale.max(1e-9);
|
||||
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
|
||||
let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y));
|
||||
let rot = environment_rotation(ctx);
|
||||
let (iw, ih) = (img.width as f64, img.height as f64);
|
||||
canvas.rows_mut().for_each(|(y, row)| {
|
||||
let cy = (y as f64 + 0.5 + oy) / s;
|
||||
for (x, d) in row.iter_mut().enumerate() {
|
||||
let cx = (x as f64 + 0.5 + ox) / s;
|
||||
let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
|
||||
let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot);
|
||||
// Wrap horizontally across the seam.
|
||||
let sx = (u as f64 * iw).clamp(0.5, iw - 0.5);
|
||||
let sy = (v as f64 * ih).clamp(0.5, ih - 0.5);
|
||||
let p = img.sample_bilinear(sx, sy);
|
||||
let k = 1.0 - p[3] * op;
|
||||
*d = [p[0] * op + d[0] * k, p[1] * op + d[1] * k, p[2] * op + d[2] * k, p[3] * op + d[3] * k];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user