mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M4.11: one motion blur gate for layers, masks, effects and Advanced 3D
`Renderer::mb_on` (render options × the comp's switch × the layer's switch as the precomp layers above limit it) now decides everywhere: - Mask motion blur checked only the comp's switch and the layer's own switch, so Render Settings ▸ Motion Blur ▸ Off for All Layers and a precomp layer with motion blur off still blurred masks; it now takes the gate (`masks::MaskBlur`) and the comp's Samples Per Frame (four in Draft) instead of a fixed eight. - Effects that read the shutter (`EffectEnv::shutter`) get it only when the layer is motion blurred in this render. - Advanced 3D layers follow the precomp layers' Motion Blur switches too. - The layer cache key of a layer with masks or effects includes whether it is blurred and the comp's shutter and samples, so a buffer blurred for the viewer is never reused by a render with motion blur off. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0b5cff1743
commit
995d99f938
6 files changed
+122
-45
No files matched your search
+15
-11
@@ -478,21 +478,21 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
|
||||
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
|
||||
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
|
||||
/// layers…).
|
||||
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option<u64> {
|
||||
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
|
||||
// Time effects see property values at other times. Keyframes are part of the hashed
|
||||
// structure (and the layer time is folded in), but expressions may read other layers at
|
||||
// those times, which the key cannot see: such layers are not cached.
|
||||
if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) {
|
||||
return None;
|
||||
}
|
||||
key_with(ctx, layer, scale, draft, false)
|
||||
key_with(ctx, layer, scale, draft, blur, false)
|
||||
}
|
||||
|
||||
/// Cache key for a layer's *input* at the context time: source → masks → its first `effects`
|
||||
/// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here
|
||||
/// (keyed by item and source time), since Time effects read many neighbouring frames.
|
||||
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, effects: usize) -> Option<u64> {
|
||||
let base = key_with(ctx, layer, scale, draft, true)?;
|
||||
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option<u64> {
|
||||
let base = key_with(ctx, layer, scale, draft, blur, true)?;
|
||||
let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3);
|
||||
effects.hash(&mut h);
|
||||
Some(h.finish())
|
||||
@@ -515,18 +515,19 @@ fn has_expression(g: &PropGroup) -> bool {
|
||||
|
||||
/// Cache key for an adjustment layer's footprint (its source through its masks, see
|
||||
/// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame.
|
||||
pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option<u64> {
|
||||
Some(derive(key_any(ctx, layer, scale, draft, false)?, 0xf007_9417))
|
||||
pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
|
||||
Some(derive(key_any(ctx, layer, scale, draft, blur, false)?, 0xf007_9417))
|
||||
}
|
||||
|
||||
fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option<u64> {
|
||||
fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option<u64> {
|
||||
if layer.switches.adjustment {
|
||||
return None;
|
||||
}
|
||||
key_any(ctx, layer, scale, draft, footage)
|
||||
key_any(ctx, layer, scale, draft, blur, footage)
|
||||
}
|
||||
|
||||
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option<u64> {
|
||||
/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`).
|
||||
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option<u64> {
|
||||
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
|
||||
match &layer.source {
|
||||
LayerSource::Solid { item } => {
|
||||
@@ -558,11 +559,14 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool)
|
||||
layer.id.hash(&mut h);
|
||||
hash_debug(&mut h, &layer.source);
|
||||
hash_debug(&mut h, &layer.switches);
|
||||
// Mask motion blur samples the comp's shutter.
|
||||
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
|
||||
// Mask motion blur and effects that read the shutter depend on whether the layer is motion
|
||||
// blurred in this render and on the comp's shutter.
|
||||
if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) {
|
||||
blur.hash(&mut h);
|
||||
ctx.comp.enable_motion_blur.hash(&mut h);
|
||||
ctx.comp.shutter_angle.to_bits().hash(&mut h);
|
||||
ctx.comp.shutter_phase.to_bits().hash(&mut h);
|
||||
ctx.comp.motion_blur_samples.hash(&mut h);
|
||||
}
|
||||
for c in &layer.props.children {
|
||||
// Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]).
|
||||
|
||||
+22
-13
@@ -841,11 +841,7 @@ impl<'a> Renderer<'a> {
|
||||
effect_index: 0,
|
||||
working_space: self.pipe.space,
|
||||
working_linear: self.pipe.linear,
|
||||
shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some((
|
||||
ctx.comp.shutter_angle,
|
||||
ctx.comp.shutter_phase,
|
||||
ctx.comp.motion_blur_samples,
|
||||
)),
|
||||
shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)),
|
||||
};
|
||||
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
|
||||
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
|
||||
@@ -1085,7 +1081,7 @@ impl<'a> Renderer<'a> {
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
// A solid is filled with a quantised colour: only masks can take it off the grid.
|
||||
let solid = matches!(layer.source, LayerSource::Solid { .. });
|
||||
if masks::apply(ctx, layer, &mut buf) || !solid {
|
||||
if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid {
|
||||
self.pipe.quantize(&mut buf.img);
|
||||
}
|
||||
Some(buf)
|
||||
@@ -1152,7 +1148,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft));
|
||||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
@@ -1173,7 +1169,7 @@ impl<'a> Renderer<'a> {
|
||||
/// (what Time effects read at neighbouring times). Served from / stored in the layer cache
|
||||
/// under its own key, so scrubbing reuses frames rendered for earlier output frames.
|
||||
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
|
||||
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects));
|
||||
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
@@ -1285,11 +1281,22 @@ impl<'a> Renderer<'a> {
|
||||
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
}
|
||||
|
||||
/// Whether a layer is motion blurred.
|
||||
fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||||
/// Whether a layer is motion blurred: the render options, the comp's switch and the layer's
|
||||
/// switch (limited by the precomp layers above when switches affect nested comps). Layer
|
||||
/// motion, mask motion blur, effects that read the shutter and Advanced 3D all ask this.
|
||||
pub(crate) fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||||
self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer)
|
||||
}
|
||||
|
||||
/// What a layer's mask motion blur may do in this render (see [`masks::MaskBlur`]).
|
||||
fn mask_blur(&self, ctx: &EvalCtx, layer: &Layer) -> masks::MaskBlur {
|
||||
masks::MaskBlur {
|
||||
allowed: self.opts.motion_blur && ctx.comp.enable_motion_blur && self.inherited.is_none_or(|(_, mb)| mb),
|
||||
layer: self.layer_motion_blur(layer),
|
||||
samples: if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples as usize },
|
||||
}
|
||||
}
|
||||
|
||||
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
|
||||
/// layer gets about one sample per output pixel its corners travel during the shutter,
|
||||
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
|
||||
@@ -1545,7 +1552,7 @@ impl<'a> Renderer<'a> {
|
||||
/// Adjustment layer: effects applied to the comp below, limited to the layer's footprint.
|
||||
fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) {
|
||||
let Some(mut foot) = self.source(ctx, layer) else { return };
|
||||
let _ = masks::apply(ctx, layer, &mut foot);
|
||||
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
|
||||
let mut matte = Image::new(canvas.width, canvas.height);
|
||||
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
|
||||
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
|
||||
@@ -1708,14 +1715,14 @@ impl<'a> Renderer<'a> {
|
||||
/// its effects show. `None` = the layer changes nothing.
|
||||
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
// Cached, so an accelerator sees the same buffer (and uploads it once) every frame.
|
||||
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft));
|
||||
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
return Some(b);
|
||||
}
|
||||
let mut foot = self.source(ctx, layer)?;
|
||||
let _ = masks::apply(ctx, layer, &mut foot);
|
||||
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
|
||||
let foot = Arc::new(foot);
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
c.insert(k, foot.clone());
|
||||
@@ -1914,6 +1921,8 @@ mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_blur;
|
||||
#[cfg(test)]
|
||||
mod tests_nested;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
|
||||
+28
-17
@@ -10,29 +10,40 @@ use rayon::prelude::*;
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
/// Sub-frame samples for mask motion blur (animated mask paths only).
|
||||
const MASK_BLUR_SAMPLES: usize = 8;
|
||||
/// What mask motion blur may do in a render (from the renderer's motion blur gate).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct MaskBlur {
|
||||
/// Motion blur is on in this render: the render options, the comp's switch and the Motion
|
||||
/// Blur switches of the precomp layers above (when switches affect nested comps).
|
||||
pub allowed: bool,
|
||||
/// The layer's own Motion Blur switch, as those precomp layers limit it (Same As Layer).
|
||||
pub layer: bool,
|
||||
/// Sub-frame samples (the comp's Samples Per Frame; fewer in Draft).
|
||||
pub samples: usize,
|
||||
}
|
||||
|
||||
/// Coverage of one mask in buffer pixels (before mode/opacity/invert). With mask motion blur
|
||||
/// (Layer ▸ Mask ▸ Motion Blur: On, or Same As Layer with the layer's switch on) an animated
|
||||
/// path is averaged over the comp's shutter.
|
||||
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
|
||||
let mb = match g.kind {
|
||||
GroupKind::Mask { motion_blur, .. } => match motion_blur {
|
||||
MaskMotionBlur::On => true,
|
||||
MaskMotionBlur::Off => false,
|
||||
MaskMotionBlur::SameAsLayer => layer.switches.motion_blur,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf, blur: MaskBlur) -> Option<Mask> {
|
||||
let mb = blur.allowed
|
||||
&& match g.kind {
|
||||
GroupKind::Mask { motion_blur, .. } => match motion_blur {
|
||||
MaskMotionBlur::On => true,
|
||||
MaskMotionBlur::Off => false,
|
||||
MaskMotionBlur::SameAsLayer => blur.layer,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
let animated = g.get("path").is_some_and(|p| p.keys.len() > 1 || p.has_expression());
|
||||
if mb && animated && ctx.comp.enable_motion_blur {
|
||||
if mb && animated {
|
||||
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: Option<Mask> = None;
|
||||
let k = 1.0 / MASK_BLUR_SAMPLES as f32;
|
||||
for i in 0..MASK_BLUR_SAMPLES {
|
||||
let f = phase + angle * i as f64 / (MASK_BLUR_SAMPLES - 1) as f64;
|
||||
let n = blur.samples.max(2);
|
||||
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 Some(c) = coverage_at(&sub, layer, g, buf) else { continue };
|
||||
match &mut acc {
|
||||
@@ -121,7 +132,7 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShap
|
||||
|
||||
/// Apply the layer's masks to its buffer.
|
||||
/// Returns whether any mask was applied.
|
||||
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) -> bool {
|
||||
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> bool {
|
||||
let Some(masks) = layer.masks() else { return false };
|
||||
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect();
|
||||
if list.is_empty() {
|
||||
@@ -133,7 +144,7 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) -> bool {
|
||||
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
|
||||
for g in list {
|
||||
let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue };
|
||||
let Some(mut cov) = coverage(ctx, layer, g, buf) else { continue };
|
||||
let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue };
|
||||
let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
|
||||
cov.data.par_iter_mut().for_each(|c| {
|
||||
if inverted {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Mask motion blur follows the renderer's motion blur gate: the render options (Render
|
||||
//! Settings ▸ Motion Blur, previews), the comp's switch and the precomp layers above.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Node, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
/// A white 100×100 solid whose rect mask jumps from x 10 (frame 0) to x 50 (frame 1), Mask
|
||||
/// Motion Blur On, the layer's own switch off. Rendered half way, unblurred the mask covers x
|
||||
/// 30..50; blurred over the shutter it reaches down to x ≈ 20.
|
||||
fn project(comp_blur: bool) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
comp.enable_motion_blur = comp_blur;
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 100, height: 100, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (100, 100), None);
|
||||
let mut next = p.next_id;
|
||||
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([20.0, 50.0], 20.0, 100.0), MaskMode::Add, [255, 0, 0]);
|
||||
p.next_id = next;
|
||||
if let GroupKind::Mask { motion_blur, .. } = &mut m.kind {
|
||||
*motion_blur = MaskMotionBlur::On;
|
||||
}
|
||||
let path = m.get_mut("path").unwrap();
|
||||
path.keys = vec![
|
||||
Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([20.0, 50.0], 20.0, 100.0))),
|
||||
Keyframe::new(FrameRate::FPS_30.tick_of(1), Value::Path(ShapePath::rect([60.0, 50.0], 20.0, 100.0))),
|
||||
];
|
||||
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
|
||||
l.switches.motion_blur = false;
|
||||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 {
|
||||
let half = Tick::from_seconds_f64(0.5 / 30.0);
|
||||
Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_motion_blur_follows_the_render_and_the_comp() {
|
||||
let (p, cid) = project(true);
|
||||
let a = alpha_at_25(&p, cid, true);
|
||||
assert!(a > 0.05 && a < 0.95, "blurred: {a}");
|
||||
assert!(alpha_at_25(&p, cid, false) < 0.01, "Render Settings ▸ Motion Blur off");
|
||||
let (p, cid) = project(false);
|
||||
assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off");
|
||||
}
|
||||
@@ -157,8 +157,8 @@ fn wiggly_selector_is_deterministic_and_time_dependent() {
|
||||
assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9));
|
||||
assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6));
|
||||
// The layer cache keys a wiggly text layer by time.
|
||||
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false);
|
||||
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false);
|
||||
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false, false);
|
||||
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, false);
|
||||
assert_ne!(k1, k2);
|
||||
// Renders identically twice.
|
||||
let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
|
||||
|
||||
@@ -882,9 +882,10 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)
|
||||
build_at(r, ctx, None, run, out)
|
||||
}
|
||||
|
||||
/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option).
|
||||
/// Whether a layer's motion is blurred (the renderer's gate: Motion Blur switch, comp setting,
|
||||
/// render option and the precomp layers above).
|
||||
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
|
||||
r.mb_on(ctx, layer)
|
||||
}
|
||||
|
||||
/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights
|
||||
|
||||
Reference in new issue
Block a user