Merge branch 'worktree-agent-afea1146abae6192c'

# Conflicts:
#	apps/effectcraft-cli/Cargo.toml
#	apps/effectcraft-cli/src/main.rs
#	crates/render/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-01 22:35:20 -04:00
15 changed files with 1267 additions and 85 deletions
+245
View File
@@ -0,0 +1,245 @@
//! Layer render cache.
//!
//! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the
//! layer's own non-transform properties, its source item and the render options — not on its
//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed
//! by a content hash of exactly those inputs, *evaluated at the frame time*, so:
//!
//! * a static layer (or one whose only animation is in Transform) renders once and is reused on
//! every frame while scrubbing or playing;
//! * editing one layer re-renders only that layer — every other layer's cached pixels stay valid
//! because their keys do not change;
//! * any change to a property value, keyframe, expression result, effect, mask, switch or source
//! item changes the key, so stale pixels are never returned.
//!
//! Layers whose pixels depend on time in ways the property values do not capture (time-based
//! effects such as Noise or Wave Warp, Wiggle Paths, footage, precomps) either fold the layer
//! time into the key or are not cached.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use effectcraft_effects::Buf;
use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
use crate::eval::EvalCtx;
/// Thread-safe, memory-budgeted cache of processed layer buffers.
pub struct LayerCache {
inner: Mutex<Inner>,
}
struct Entry {
buf: Arc<Buf>,
bytes: usize,
last_use: u64,
}
struct Inner {
map: HashMap<u64, Entry>,
bytes: usize,
budget: usize,
clock: u64,
hits: u64,
misses: u64,
}
/// Hit/miss counters and memory use (for perf readouts and tests).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CacheStats {
pub entries: usize,
pub bytes: usize,
pub hits: u64,
pub misses: u64,
}
impl Default for LayerCache {
fn default() -> Self {
LayerCache::new(1 << 30)
}
}
impl LayerCache {
/// A cache holding at most `budget` bytes of pixels.
pub fn new(budget: usize) -> LayerCache {
LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0 }) }
}
pub fn get(&self, key: u64) -> Option<Arc<Buf>> {
let mut g = self.inner.lock().ok()?;
g.clock += 1;
let now = g.clock;
match g.map.get_mut(&key) {
Some(e) => {
e.last_use = now;
let b = e.buf.clone();
g.hits += 1;
Some(b)
}
None => {
g.misses += 1;
None
}
}
}
pub fn insert(&self, key: u64, buf: Arc<Buf>) {
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
let Ok(mut g) = self.inner.lock() else { return };
if bytes > g.budget / 4 {
return;
}
g.clock += 1;
let now = g.clock;
if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) {
g.bytes -= old.bytes;
}
g.bytes += bytes;
if g.bytes > g.budget {
// Evict least-recently used entries down to 3/4 of the budget.
let target = g.budget / 4 * 3;
let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect();
by_age.sort_unstable();
for (_, k) in by_age {
if g.bytes <= target {
break;
}
if let Some(e) = g.map.remove(&k) {
g.bytes -= e.bytes;
}
}
}
}
pub fn clear(&self) {
if let Ok(mut g) = self.inner.lock() {
g.map.clear();
g.bytes = 0;
}
}
pub fn stats(&self) -> CacheStats {
self.inner.lock().map(|g| CacheStats { entries: g.map.len(), bytes: g.bytes, hits: g.hits, misses: g.misses }).unwrap_or_default()
}
}
/// FNV-1a style 64-bit hasher fed by `Debug` output (exact float formatting) and raw values.
struct KeyHasher(u64);
impl Hasher for KeyHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 ^= b as u64;
self.0 = self.0.wrapping_mul(0x0100_0000_01b3);
}
}
}
impl std::fmt::Write for KeyHasher {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
Hasher::write(self, s.as_bytes());
Ok(())
}
}
fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) {
use std::fmt::Write;
let _ = write!(h, "{v:?}");
Hasher::write_u8(h, 0xff);
}
/// Time-based layer content: does any part of the source/effects read the clock directly
/// (rather than through property values)?
fn time_dependent(layer: &Layer) -> bool {
if layer.switches.effects
&& let Some(fx) = layer.effects()
{
for g in fx.groups() {
if let effectcraft_project::GroupKind::Effect { effect } = &g.kind
&& g.enabled
&& effectcraft_effects::is_time_dependent(effect)
{
return true;
}
}
}
if matches!(layer.source, LayerSource::Shape)
&& let Some(c) = layer.props.sub("contents")
{
return group_has(c, "wiggle");
}
false
}
fn group_has(g: &PropGroup, match_id: &str) -> bool {
g.children.iter().any(|c| match c {
Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id),
Node::Prop(_) => false,
})
}
/// Feed every property of `g` that can change over time (keyframes or expression) as its value
/// at the context time. The static structure is hashed separately.
fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
for c in &g.children {
match c {
Node::Prop(p) => {
if !p.keys.is_empty() || p.has_expression() {
p.uid.hash(h);
hash_debug(h, &ctx.value(layer, p));
}
}
Node::Group(sub) => hash_values(h, ctx, layer, sub),
}
}
}
/// 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> {
if layer.switches.adjustment {
return None;
}
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
match &layer.source {
LayerSource::Solid { item } => {
let it = ctx.project.item(*item)?;
let ItemKind::Solid(s) = &it.kind else { return None };
hash_debug(&mut h, s);
}
LayerSource::Text | LayerSource::Shape => {}
_ => return None,
}
ctx.comp_id.hash(&mut h);
ctx.comp.width.hash(&mut h);
ctx.comp.height.hash(&mut h);
scale.to_bits().hash(&mut h);
draft.hash(&mut h);
layer.id.hash(&mut h);
hash_debug(&mut h, &layer.source);
hash_debug(&mut h, &layer.switches);
for c in &layer.props.children {
if let Node::Group(g) = c
&& g.match_id == "transform"
{
continue;
}
// Static structure: values, keyframes, expressions, enabled flags, effect ids, modes.
hash_debug(&mut h, c);
if let Node::Group(g) = c {
hash_values(&mut h, ctx, layer, g);
}
}
if time_dependent(layer) {
layer.layer_time(ctx.time).0.hash(&mut h);
// Effects also see the layer's time mapping.
layer.start_time.0.hash(&mut h);
layer.stretch.to_bits().hash(&mut h);
}
Some(h.finish())
}
+79 -25
View File
@@ -7,6 +7,7 @@
//! Adjustment layers run their effects on everything below, limited by their own bounds/masks.
pub mod audio;
pub mod cache;
pub mod eval;
pub mod masks;
pub mod shapes;
@@ -14,6 +15,7 @@ pub mod text;
use std::sync::Arc;
pub use cache::{CacheStats, LayerCache};
use effectcraft_color::BlendMode;
use effectcraft_effects::{Buf, EffectCtx, Params};
use effectcraft_geom::{Mat3, vec2};
@@ -65,13 +67,32 @@ pub struct Renderer<'a> {
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub opts: RenderOpts,
/// Processed-layer cache shared across frames (see [`cache`]). `None` renders everything.
pub cache: Option<&'a LayerCache>,
/// Optional per-layer timing sink (`frame --bench` / perf readouts).
pub profile: Option<&'a std::sync::Mutex<Vec<LayerTiming>>>,
/// Current nesting depth (precomp recursion guard).
depth: usize,
}
/// Time spent on one layer of one frame (milliseconds).
#[derive(Clone, Debug, Default)]
pub struct LayerTiming {
pub depth: usize,
pub layer: String,
/// Source + masks + effects (cache lookup only, on a hit).
pub process_ms: f64,
/// Per effect: (effect id, ms). Empty on a cache hit.
pub effects: Vec<(String, f64)>,
/// Transform + matte + blend.
pub composite_ms: f64,
/// The processed buffer came from the layer cache.
pub cached: bool,
}
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer { project, footage, expr: None, opts, depth: 0 }
Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 }
}
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
@@ -132,7 +153,11 @@ impl<'a> Renderer<'a> {
p
}
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool) -> Buf {
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf {
self.apply_effects_timed(ctx, layer, buf, adjustment, None)
}
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, mut timing: Option<&mut Vec<(String, f64)>>) -> Buf {
if !layer.switches.effects {
return buf;
}
@@ -148,7 +173,11 @@ impl<'a> Renderer<'a> {
let Some(spec) = effectcraft_effects::find(effect) else { continue };
let params = self.effect_params(ctx, layer, g);
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
let t0 = std::time::Instant::now();
buf = effectcraft_effects::apply(spec, &ectx, buf);
if let Some(v) = timing.as_deref_mut() {
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
}
}
buf
}
@@ -171,7 +200,7 @@ impl<'a> Renderer<'a> {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 };
let sub = Renderer { depth: self.depth + 1, ..*self };
let lt = layer.layer_time(ctx.time);
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
}
@@ -199,10 +228,28 @@ impl<'a> Renderer<'a> {
}
/// Fully processed layer buffer (source → masks → effects).
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
/// Served from the layer cache when the layer's content key matches.
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.layer_buf_timed(ctx, layer, None)
}
fn layer_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
if let Some(t) = timing.as_deref_mut() {
t.cached = true;
}
return Some(b);
}
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
Some(self.apply_effects(ctx, layer, buf, false))
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, timing.map(|t| &mut t.effects)));
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, buf.clone());
}
Some(buf)
}
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
@@ -238,25 +285,14 @@ impl<'a> Renderer<'a> {
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
let phase = ctx.comp.shutter_phase / 360.0;
// Sub-samples sum straight into one buffer (each warp is row-parallel).
let mut acc = Image::new(target.width, target.height);
let accum: Vec<Image> = (0..samples)
.into_par_iter()
.map(|i| {
let f = phase + angle * i as f64 / (samples - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let m = self.buf_matrix(&sub, layer, buf);
let mut img = Image::new(target.width, target.height);
composite_warp(&mut img, &buf.img, &m, &WarpOpts { mode: BlendMode::Normal, opacity: 1.0, ..opts });
img
})
.collect();
let k = 1.0 / samples as f32;
for img in &accum {
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, b)| {
for c in 0..4 {
a[c] += b[c] * k;
}
});
for i in 0..samples {
let f = phase + angle * i as f64 / (samples - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let m = self.buf_matrix(&sub, layer, buf);
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, &m, opts.sampling, k);
}
target.blend_from(&acc, mode, opacity, layer.id.0 as u32);
}
@@ -270,16 +306,34 @@ impl<'a> Renderer<'a> {
self.draw_adjustment(ctx, layer, canvas, opacity);
return;
}
let Some(buf) = self.layer_buf(ctx, layer) else { return };
let Some(prof) = self.profile else {
if let Some(buf) = self.layer_buf(ctx, layer) {
self.composite_layer(ctx, layer, &buf, canvas, opacity);
}
return;
};
let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() };
let t0 = std::time::Instant::now();
let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return };
let t1 = std::time::Instant::now();
self.composite_layer(ctx, layer, &buf, canvas, opacity);
timing.process_ms = (t1 - t0).as_secs_f64() * 1e3;
timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3;
if let Ok(mut v) = prof.lock() {
v.push(timing);
}
}
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
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 matte.is_none() && !preserve {
self.place(ctx, layer, &buf, canvas, layer.blend_mode, opacity);
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
return;
}
// Render in isolation, then matte / preserve transparency, then blend.
let mut iso = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &buf, &mut iso, BlendMode::Normal, 1.0);
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
if let Some((m, kind)) = matte {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
+171
View File
@@ -160,3 +160,174 @@ fn effects_run_in_pipeline() {
let c = img.get(100, 50);
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, t)
}
fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, params: &[(&str, Value)]) {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
p.next_id = next;
for (k, v) in params {
g.prop_mut(k).unwrap().value = v.clone();
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
/// A comp with a static effected solid, an animated-trim shape, a moving text layer with a blur.
fn cache_scene() -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
add_effect(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]);
let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let e = build::shape_ellipse(&mut ids, [60.0, 60.0], [0.0, 0.0]);
let tr = build::shape_trim(&mut ids, 0.0, 0.0, 0.0);
let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0);
let g = build::shape_group(&mut ids, "Ring", vec![e, tr, s]);
shape.props.sub_mut("contents").unwrap().children.push(g.into());
}
p.next_id = next;
shape.props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(10.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
let mut text = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() }));
text.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))];
add_effect(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]);
let c = p.comp_mut(cid).unwrap();
c.layers = vec![text, shape, bg];
(p, cid)
}
fn assert_same(a: &crate::Image, b: &crate::Image, what: &str) {
assert_eq!((a.width, a.height), (b.width, b.height), "{what}");
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
for c in 0..4 {
assert!((p[c] - q[c]).abs() < 1e-6, "{what}: pixel {i}: {p:?} vs {q:?}");
}
}
}
#[test]
fn cached_frames_match_uncached() {
let (p, cid) = cache_scene();
let cache = crate::LayerCache::default();
for f in [0.0, 0.25, 0.5, 0.5, 1.2, 1.5, 0.25] {
let t = Tick::from_seconds_f64(f);
assert_same(&render_cached(&p, cid, t, Some(&cache)), &render_cached(&p, cid, t, None), &format!("t={f}"));
}
// bg is static, text only moves (transform), the shape is static after 1 s: lots of reuse.
let st = cache.stats();
assert!(st.hits >= 10, "{st:?}");
}
#[test]
fn static_and_transform_only_layers_are_reused() {
let (p, cid) = cache_scene();
let cache = crate::LayerCache::default();
render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache));
let before = cache.stats();
render_cached(&p, cid, Tick::from_seconds_f64(0.4), Some(&cache));
let after = cache.stats();
// bg (static) + text (only its position animates) hit; the shape's trim animates: miss.
assert_eq!(after.hits - before.hits, 2, "{before:?} {after:?}");
assert_eq!(after.misses - before.misses, 1, "{before:?} {after:?}");
}
type Edit = Box<dyn Fn(&mut Project, ItemId)>;
/// Every kind of edit re-renders the affected layer: the cached render of the edited project
/// equals a fresh uncached render, and differs from the unedited frame.
#[test]
fn edits_invalidate_cached_layers() {
let (p, cid) = cache_scene();
let t = Tick::from_seconds_f64(0.5);
let edits: Vec<(&str, Edit)> = vec![
(
"effect param value",
Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.prop_mut("effects/#1/white").unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0])),
),
(
"effect param keyframes",
Box::new(|p, cid| {
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/blurriness").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(30.0))]
}),
),
("effect disabled", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false)),
("effects switch off", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[0].switches.effects = false)),
(
"effect added",
Box::new(|p, cid| {
let mut l = p.comp_mut(cid).unwrap().layers[2].clone();
add_effect(p, &mut l, "ec.channel.invert", &[]);
p.comp_mut(cid).unwrap().layers[2] = l;
}),
),
(
"shape keyframe moved",
Box::new(|p, cid| {
p.comp_mut(cid).unwrap().layers[1].props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys[1].time = Tick::from_seconds_f64(2.0)
}),
),
(
"text changed",
Box::new(|p, cid| {
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("text/sourceText").unwrap().value =
Value::Text(Box::new(TextDoc { text: "XYZ".into(), size: 40.0, ..Default::default() }))
}),
),
(
"solid colour",
Box::new(|p, cid| {
let LayerSource::Solid { item } = p.comp(cid).unwrap().layers[2].source else { unreachable!() };
if let ItemKind::Solid(s) = &mut p.item_mut(item).unwrap().kind {
s.color = [0.9, 0.1, 0.1];
}
p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false;
}),
),
(
"mask added",
Box::new(|p, cid| {
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[2].props.sub_mut("masks").unwrap().children.push(m.into());
}),
),
("layer time shifted", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[1].start_time = Tick::from_seconds_f64(0.3))),
];
for (what, edit) in edits {
let cache = crate::LayerCache::default();
let orig = render_cached(&p, cid, t, Some(&cache));
let mut q = p.clone();
edit(&mut q, cid);
let cached = render_cached(&q, cid, t, Some(&cache));
let fresh = render_cached(&q, cid, t, None);
assert_same(&cached, &fresh, what);
assert!(orig.data.iter().zip(&fresh.data).any(|(a, b)| (a[0] - b[0]).abs() + (a[3] - b[3]).abs() > 1e-3), "{what}: edit had no visible effect");
}
}
#[test]
fn time_dependent_effects_rerender_every_frame() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
add_effect(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let cache = crate::LayerCache::default();
let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache));
let b = render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache));
assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2");
assert!(a != b, "noise must animate");
}