mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
f08d090cd8
commit
1ccf7cb910
3 files changed
+74
-8
No files matched your search
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user