Export: Render Queue jobs keep a layer cache, so static layers render once (#413)

Cause: pipeline::Cx::render_at built the job's Renderer without a layer
cache, so every layer of every frame rendered from scratch (source, masks,
effects, styles), and the GPU compositor, whose uploads are keyed by the
cached buffers, uploaded every layer again on every frame.

Fix: each job (Cx) owns a LayerCache, the cache type the viewer uses, with
its default budget (1 GiB, the default Settings > Memory & CPU layer cache),
shared by the job's parallel frames and dropped with the job. With deferred
GPU readbacks it holds the accelerator's miss gate, as the job workers'
caches do. Cx::renderer builds the job's renderer (options, accelerator,
cache) for render_at and the test.

Tests: pipeline::tests::layers_render_once_per_job exports ten frames of a
solid animated only in Transform: the cache misses of the first frame are
the only ones (it fails without the cache: no layer is cached), and every
frame equals the frame rendered without the cache.

Diagnosis by @pat-the-wizard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 22:26:16 +07:00
1 parent f08d090cd8
commit 1ccf7cb910
3 files changed
+74 -8

No files matched your search

+2 -1
View File
@@ -21,7 +21,8 @@
//! render log (Errors Only / Plus Settings / Plus Per Frame Info) is written next to the output.
//!
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
//! the encoder in order. They share the job's layer cache, as viewer frames share the viewer's:
//! static and transform-only layers render once per job. Progress is reported after each batch through a callback that can cancel
//! the job by returning `false`. Codecs and the MP4/MOV muxer are FilmCraft's pure-Rust crates
//! (plan ADR 0001); nothing outside this crate touches them.
//!
+70 -6
View File
@@ -9,17 +9,21 @@ use effectcraft_project::render_queue::{
};
use effectcraft_project::{Comp, Project};
use effectcraft_raster::Image;
use effectcraft_render::{RenderOpts, Renderer};
use effectcraft_render::{LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use web_time::Instant;
use crate::{Job, RenderQuality, out};
/// A job being exported: the job, the project with the Render Settings overrides applied, and
/// per-frame timing for the render log.
/// A job being exported: the job, the project with the Render Settings overrides applied, the
/// job's layer cache and per-frame timing for the render log.
pub(crate) struct Cx<'a> {
pub job: &'a Job<'a>,
pub project: Project,
/// Processed layer buffers shared by the job's frames, as the viewer's layer cache: static
/// and transform-only layers render once per job (the GPU compositor then uploads them
/// once). Dropped with the job.
cache: LayerCache,
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
/// Files written to an overflow folder.
@@ -69,14 +73,19 @@ impl<'a> Cx<'a> {
let mut project = job.project.clone();
apply_overrides(&mut project, job);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
Cx { job, project, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }
let cache = LayerCache::default();
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
cache.set_gate(job.accel.and_then(|a| a.miss_gate()));
Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }
}
pub fn comp(&self) -> Option<&Comp> {
self.project.comp(self.job.comp)
}
async fn render_at(&self, t: Tick) -> Image {
/// The renderer of the job's frames: the Render Settings' options, the job's accelerator
/// and layer cache.
fn renderer(&self) -> Renderer<'_> {
let s = self.job.settings;
let opts = RenderOpts {
scale: s.resolution.clamp(0.01, 4.0),
@@ -94,8 +103,13 @@ impl<'a> Cx<'a> {
let mut r = Renderer::new(&self.project, self.job.footage, opts);
r.expr = self.job.expr;
r.accel = self.job.accel;
r.cache = Some(&self.cache);
r
}
async fn render_at(&self, t: Tick) -> Image {
// With deferred GPU readbacks (a browser worker) the frame renders in passes.
effectcraft_render::passes::comp_frame(&r, self.job.comp, t).await
effectcraft_render::passes::comp_frame(&self.renderer(), self.job.comp, t).await
}
/// Whether frames render in passes (an accelerator with deferred readbacks).
@@ -350,6 +364,56 @@ fn premultiplied8(img: &Image) -> Vec<u8> {
mod tests {
use super::*;
#[test]
fn layers_render_once_per_job() {
// #413: a job rendered every layer of every frame from scratch (no layer cache).
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
use effectcraft_project::{ItemKind, LayerSource, Solid, build};
use effectcraft_render::NoFootage;
use effectcraft_time::FrameRate;
// A solid animated only in Transform: its pixels render once, then move.
let mut p = Project::default();
let mut comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 16, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (16, 16), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([8.0, 24.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([56.0, 24.0, 0.0]))];
comp.layers.push(l);
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let job = Job {
project: &p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: Default::default(),
};
let cx = Cx::new(&job);
let comp = cx.comp().unwrap();
cx.frame(comp, 0);
let renders = cx.cache.stats().misses;
assert!(renders > 0);
for i in 1..10 {
let img = cx.frame(comp, i);
// The frames are the ones rendered without a cache.
let t = settings.frame_time(comp, i);
let mut uncached = cx.renderer();
uncached.cache = None;
assert_eq!(img.data, uncached.comp_frame(cid, t).data, "frame {i}");
}
let stats = cx.cache.stats();
assert_eq!(stats.misses, renders, "the solid rendered on the first frame only");
assert!(stats.hits >= 9, "{stats:?}");
}
#[test]
fn interleave_takes_alternate_lines() {
let a = Image::filled(3, 4, [1.0, 0.0, 0.0, 1.0]);
+2 -1
View File
@@ -161,7 +161,8 @@ collapsed 3D precomps (drawn flattened), morph targets, extruded strokes.
A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of
its evaluated inputs, excluding the transform. Static and transform-only layers render once;
editing one layer re-renders only that layer. Effects that read the clock directly are declared in
editing one layer re-renders only that layer. Each Render Queue job keeps its own layer cache
for its frames. Effects that read the clock directly are declared in
`effects::TIME_DEPENDENT`, and a test checks every registered effect against that list.
The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by project