Merge remote-tracking branch 'origin/gpu-upload-once-3333' into rq-readback-3335

This commit is contained in:
vangel76
2026-11-09 00:55:36 +01:00
10 changed files with 818 additions and 30 deletions
+64 -9
View File
@@ -128,6 +128,11 @@ const POOL_BUDGET: usize = 256 << 20;
/// miss (a new allocation), never a wrong pixel.
const STALE_FRAMES: u64 = 2;
/// The pool owner of uploaded textures: never registered by an [`Enc`] (their ids start at 1),
/// so an upload takes only from the shared free list ([`Pool::take`], textures no open encoder
/// can read) and gives its texture back through the guarded [`Pool::release`].
const UPLOAD_OWNER: u64 = 0;
/// Readback staging buffers kept for reuse across sizes (bytes).
const STAGING_BUDGET: u64 = 256 << 20;
@@ -693,6 +698,10 @@ pub struct GpuContext {
dummy_tex: wgpu::TextureView,
dummy_buf: wgpu::Buffer,
uploads: Mutex<Uploads>,
/// Uploaded footage pictures, shared by the layers that show them (see [`crate::upload`]).
pictures: Mutex<crate::upload::Pictures>,
/// Reused staging buffers of large uploads (see [`crate::upload::Staging`]).
upload_staging: crate::upload::Staging,
upload_budget: std::sync::atomic::AtomicUsize,
upload_full_warned: std::sync::atomic::AtomicBool,
pool: Arc<Mutex<Pool>>,
@@ -1017,6 +1026,8 @@ impl GpuContext {
dummy,
dummy_buf,
uploads: Mutex::new(Uploads::default()),
pictures: Default::default(),
upload_staging: Default::default(),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool,
@@ -1132,11 +1143,11 @@ impl GpuContext {
/// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting
/// on `owner` (`closing_enc`) cannot free.
fn kept_bytes(&self, owner: u64, closing_enc: Option<u64>) -> u64 {
let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0);
// (uploaded layer and footage textures are pooled: the pool's leases count them)
let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0);
let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0);
let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0);
uploads + pool + staging + zeros
pool + staging + self.upload_staging.bytes() + zeros
}
#[cfg(test)]
@@ -1549,6 +1560,8 @@ impl GpuContext {
dummy: self.dummy.clone(),
dummy_buf: self.dummy_buf.clone(),
uploads: Mutex::new(Uploads::default()),
pictures: Default::default(),
upload_staging: Default::default(),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool: self.pool.clone(),
@@ -1728,16 +1741,54 @@ impl GpuContext {
return None;
}
self.transfers.count(true, img.data.len() * 16);
let g = self.image(img.width, img.height);
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
// A texture from the pool's shared free list: no open encoder can still read it, and the
// copy below runs before anything recorded after this call.
let (g, _) = self.pooled(img.width, img.height, UPLOAD_OWNER);
if !self.upload_staging.write(&self.device, &self.queue, img, &g.texture) {
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
}
Some(g)
}
/// Upload a decoded footage picture once while the footage source keeps it: every layer
/// and effect input showing the same frame shares the texture (`Renderer::footage_picture`).
pub(crate) fn upload_picture(&self, img: &Arc<Image>) -> Option<GpuImage> {
if let Some(t) = self.pictures.lock().ok().and_then(|mut p| p.get(img)) {
return Some(t);
}
let tex = self.upload_image(img)?;
if let Ok(mut p) = self.pictures.lock() {
p.insert(img, tex.clone(), crate::upload::PICTURE_BUDGET.min(self.upload_budget() / 4));
}
Some(tex)
}
/// Entries in the footage picture cache.
#[cfg(test)]
pub(crate) fn picture_entries(&self) -> usize {
self.pictures.lock().map(|p| p.len()).unwrap_or_default()
}
/// The upload staging buffers (tests).
#[cfg(test)]
pub(crate) fn upload_staging(&self) -> &crate::upload::Staging {
&self.upload_staging
}
/// A render is done: drop the footage picture textures and the upload staging buffers
/// (the next render's pictures upload again; their textures come from the pool).
pub fn trim_uploads(&self) {
if let Ok(mut p) = self.pictures.lock() {
p.clear();
}
self.upload_staging.clear();
}
/// Entries in the layer upload cache.
#[cfg(test)]
pub(crate) fn upload_entries(&self) -> usize {
@@ -1799,6 +1850,10 @@ impl GpuContext {
u.map.clear();
u.bytes = 0;
}
if let Ok(mut p) = self.pictures.lock() {
p.clear();
}
self.upload_staging.clear();
if let Ok(mut p) = self.pool.lock() {
p.clear();
}
+2
View File
@@ -84,6 +84,7 @@ mod memcap;
mod ops;
mod particles;
mod readback;
mod upload;
mod walk;
mod yuv;
@@ -439,6 +440,7 @@ impl Accelerator for Gpu {
target: "effectcraft_gpu",
"gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far"
);
self.ctx.trim_uploads();
self.ctx.trim_pool();
}
+2
View File
@@ -28,8 +28,10 @@ use crate::Gpu;
mod adjust;
mod adv3d;
mod bench_share;
mod deferred;
mod deferred_jobs;
mod footage_share;
mod fx_color;
mod fx_depth;
mod fx_distort;
+83
View File
@@ -0,0 +1,83 @@
//! Warm-frame benchmark of #3333 (run by hand): three layers of the same 1080p footage frame,
//! 32 bpc, a 30-frame sequence already decoded (as the media pool serves a warm frame), with a
//! layer cache as the Render Queue has. Prints the median and mean ms per frame and a hash of
//! every frame (to compare the pixels of two builds).
//!
//! `cargo test -p effectcraft-gpu --release --lib bench_share -- --ignored --nocapture`
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
struct Decoded {
frames: Mutex<HashMap<i64, Arc<Image>>>,
}
impl FootageSource for Decoded {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t).clamp(0, 29);
let mut m = self.frames.lock().unwrap();
Some(m.entry(i).or_insert_with(|| Arc::new(pattern(item.0 as u32 + i as u32, f.width, f.height))).clone())
}
}
#[test]
#[ignore]
fn bench_share() {
let Some(g) = gpu() else { return };
let (w, h) = (1920, 1080);
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let f = Footage {
path: "seq.exr".into(),
kind: FootageKind::Sequence,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
alpha: AlphaMode::Premultiplied,
..Default::default()
};
let fid = p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f));
for i in 0..3 {
let mut l = build::layer(&mut p, &comp, "Sequence", LayerSource::Footage { item: fid }, (w, h), None);
if i > 0 {
l.blend_mode = BlendMode::Add;
set(&mut l, "transform/opacity", Value::Scalar(25.0));
}
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
let src = Decoded { frames: Mutex::new(HashMap::new()) };
let frames = 30;
let mut runs = vec![];
let mut hashes = vec![];
for pass in 0..4 {
let cache = effectcraft_render::cache::LayerCache::new(1 << 30);
let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() });
r.accel = Some(g);
r.cache = Some(&cache);
for k in 0..frames {
let t0 = std::time::Instant::now();
let img = g.render(&r, cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame");
let ms = t0.elapsed().as_secs_f64() * 1e3;
if pass > 0 {
runs.push(ms);
}
if pass == 1 {
use std::hash::{Hash, Hasher};
let mut hs = std::collections::hash_map::DefaultHasher::new();
img.data.iter().for_each(|px| px.map(f32::to_bits).hash(&mut hs));
hashes.push(hs.finish());
}
}
}
runs.sort_by(f64::total_cmp);
let mean = runs.iter().sum::<f64>() / runs.len() as f64;
eprintln!("bench_share: {} frames, median {:.1} ms, mean {:.1} ms, min {:.1} ms", runs.len(), runs[runs.len() / 2], mean, runs[0]);
eprintln!("bench_share frame hashes: {hashes:016x?}");
}
+233
View File
@@ -0,0 +1,233 @@
//! #3333: a footage frame uploads once per frame and its texture is shared by every layer and
//! effect input that shows it; the frame is bit-identical to drawing each layer from its own copy.
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
use crate::walk::SHARED_PICTURES;
/// Keeps decoded frames as the media pool does: the same frame (at the same size) of the same
/// footage is the same `Arc` while kept. Counts the frames it decodes.
#[derive(Default)]
struct Kept {
frames: Mutex<HashMap<(ItemId, u32, u32, i64, Option<(u32, u32)>), Arc<Image>>>,
}
impl Kept {
fn get(&self, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t);
let mut m = self.frames.lock().unwrap();
let img = m.entry((item, f.width, f.height, i, size)).or_insert_with(|| {
let full = pattern(item.0 as u32 + i as u32 * 7 + f.width, f.width, f.height);
Arc::new(match size {
Some((w, h)) => effectcraft_raster::resample(&full, w, h),
None => full,
})
});
Some(img.clone())
}
}
impl FootageSource for Kept {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.get(item, f, t, None)
}
fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.get(item, f, t, Some((width, height)))
}
}
/// One footage item (a sequence: a new picture every frame) shown by `layers` layers, each
/// placed differently (the first fills the comp).
fn shared_scene(depth: BitDepth, layers: usize) -> (Scene, ItemId) {
let mut s = Scene::new(depth);
let f = Footage {
path: "seq.exr".into(),
kind: FootageKind::Sequence,
width: 97,
height: 61,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(2.0),
has_video: true,
alpha: AlphaMode::Premultiplied,
..Default::default()
};
let fid = s.p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f));
for i in 0..layers {
let mut l = build::layer(&mut s.p, &s.comp, "Sequence", LayerSource::Footage { item: fid }, (97, 61), None);
if i > 0 {
set(&mut l, "transform/rotation", Value::Scalar(11.0 * i as f64));
set(&mut l, "transform/scale", Value::Vec3([70.0 + 9.0 * i as f64, 64.0, 100.0]));
set(&mut l, "transform/position", v3(30.0 + 13.0 * i as f64, 25.0 + 4.0 * i as f64));
set(&mut l, "transform/opacity", Value::Scalar(80.0));
l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Add };
}
s.push(l);
}
(s, fid)
}
/// The GPU frame with shared pictures on and off, and the number of uploads of each.
fn render_both(g: &crate::Gpu, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick) -> (Image, Image, u64, u64) {
let mut r = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Gpu, ..opts });
r.accel = Some(g);
let before = g.ctx.transfer_stats().uploads;
let on = g.render(&r, s.cid, t).expect("the GPU renders the frame");
let uploads = g.ctx.transfer_stats().uploads - before;
SHARED_PICTURES.store(false, std::sync::atomic::Ordering::Relaxed);
let before = g.ctx.transfer_stats().uploads;
let off = g.render(&r, s.cid, t);
let copied = g.ctx.transfer_stats().uploads - before;
SHARED_PICTURES.store(true, std::sync::atomic::Ordering::Relaxed);
(on, off.expect("the GPU renders the frame from per-layer copies"), uploads, copied)
}
fn check_cpu(label: &str, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick, gpu_img: &Image) {
let cpu = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t);
let mut d = diff(&cpu, gpu_img, tolerance(s.p.settings.bit_depth));
d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32;
check(label, Some(d), 0.0);
}
/// Three layers showing the same frame upload it once; a fourth that runs an effect on it
/// (resident on the GPU) reads the same texture as its input. Every frame of the sequence
/// uploads its own picture once.
#[test]
fn three_layers_of_one_footage_frame_upload_once() {
let Some(g) = gpu() else { return };
let src = Kept::default();
let (mut s, fid) = shared_scene(BitDepth::Bpc32, 3);
for k in 0..3 {
let t = Tick::from_seconds_f64(k as f64 / 30.0);
let (on, off, uploads, copied) = render_both(g, &s, &src, opts(), t);
assert_eq!(uploads, 1, "frame {k}: three layers of one footage frame uploaded {uploads} times");
assert_eq!(copied, 3, "frame {k}: per-layer copies upload once per layer");
assert!(on.data == off.data, "frame {k}: the shared picture changed the frame");
check_cpu(&format!("shared picture frame {k}"), &s, &src, opts(), t, &on);
}
// An effect input: the blur reads the shared texture. Alone, the blurred layer uploads
// its picture (and whatever the blur itself uploads); the three plain layers showing the
// same frame add no upload.
let mut fx = build::layer(&mut s.p, &s.comp, "Blurred", LayerSource::Footage { item: fid }, (97, 61), None);
set(&mut fx, "transform/position", v3(20.0, 20.0));
s.effect(&mut fx, "ec.blur.gaussian", &[("blurriness", n(3.0))]);
let (mut alone, _) = shared_scene(BitDepth::Bpc32, 0);
alone.p = s.p.clone();
alone.p.comp_mut(alone.cid).unwrap().layers = vec![fx.clone()].into();
let (_, _, blur_uploads, _) = render_both(g, &alone, &src, opts(), Tick::from_seconds_f64(0.4));
s.push(fx);
let t = Tick::from_seconds_f64(0.5);
let (on, off, uploads, _) = render_both(g, &s, &src, opts(), t);
assert_eq!(uploads, blur_uploads, "three layers and an effect input of one footage frame uploaded {uploads} times, the effect layer alone {blur_uploads}");
assert!(on.data == off.data, "the shared picture changed the frame with an effect input");
check_cpu("shared picture with an effect input", &s, &src, opts(), t, &on);
}
/// The frame is bit-identical with shared pictures on and off wherever they apply or not: every
/// bit depth, colour management and linear blending, half resolution, a track matte, Preserve
/// Transparency, motion blur, masks, layer styles and a proxy.
#[test]
fn shared_pictures_do_not_change_the_frame() {
let Some(g) = gpu() else { return };
let src = Kept::default();
let t = Tick::from_seconds_f64(0.2);
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
let mut cases: Vec<(String, Scene, RenderOpts)> = vec![];
cases.push(("plain".into(), shared_scene(depth, 3).0, opts()));
cases.push(("half resolution".into(), shared_scene(depth, 3).0, RenderOpts { scale: 0.5, ..opts() }));
for (ws, blend_linear) in [(Some(ColorSpace::Rec709), false), (None, true)] {
let (mut s, _) = shared_scene(depth, 3);
s.p.settings.working_space = ws;
s.p.settings.blend_linear = blend_linear;
cases.push((format!("colour {ws:?} linear blending {blend_linear}"), s, opts()));
}
{
let (mut s, _) = shared_scene(depth, 3);
let comp = s.p.comp_mut(s.cid).unwrap();
comp.enable_motion_blur = true;
let matte = comp.layers[0].id;
comp.layers[1].track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Luma });
comp.layers[2].preserve_transparency = true;
comp.layers[2].switches.motion_blur = true;
let pos = comp.layers[2].props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 20.0)), Keyframe::new(Tick::from_seconds_f64(0.5), v3(90.0, 45.0))];
s.comp.enable_motion_blur = true;
cases.push(("matte, preserve transparency, motion blur".into(), s, opts()));
}
{
let (mut s, _) = shared_scene(depth, 3);
let mut next = s.p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 30.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]);
let comp = s.p.comp_mut(s.cid).unwrap();
comp.layers[0].props.sub_mut("masks").unwrap().children.push(m.into());
effectcraft_project::styles::add_style(&mut comp.layers[1], &mut Ids(&mut next), "dropShadow", &s.comp.global_light, true).unwrap();
s.p.next_id = next;
cases.push(("mask and layer style".into(), s, opts()));
}
{
let (mut s, fid) = shared_scene(depth, 3);
let proxy = Footage { path: "proxy.png".into(), kind: FootageKind::Still, width: 48, height: 30, has_video: true, ..Default::default() };
s.p.item_mut(fid).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
cases.push(("proxy".into(), s, opts()));
}
for (label, s, o) in cases {
let (on, off, _, _) = render_both(g, &s, &src, o, t);
assert!(on.data == off.data, "{label} {depth:?}: the shared picture changed the frame");
check_cpu(&format!("{label} {depth:?}"), &s, &src, o, t, &on);
}
}
}
/// The picture cache keeps no texture of a picture the footage source dropped.
#[test]
fn picture_cache_drops_textures_of_dropped_pictures() {
let Some(g) = gpu() else { return };
let before = g.ctx.picture_entries();
for i in 0..6 {
let img = Arc::new(pattern(i, 64, 64));
let up = g.ctx.upload_picture(&img).expect("upload");
let again = g.ctx.upload_picture(&img).expect("upload");
assert!(up.texture == again.texture, "the same picture uploaded twice");
drop((up, again, img));
}
let kept = g.ctx.picture_entries();
assert!(kept <= before + 1, "the picture cache kept {kept} textures of dropped pictures (had {before})");
}
/// Large uploads copy through reused staging buffers (rows padded to the copy alignment) with
/// the same pixels as `Queue::write_texture`; the buffers stay within their budget, and a
/// clear while one is in flight forgets it.
#[test]
fn staging_uploads_keep_pixels_and_reuse_buffers() {
let Some(g) = gpu() else { return };
let wait = || {
let _ = g.ctx.poll_readbacks(wgpu::PollType::wait_indefinitely());
};
// 300 px: 4800-byte rows, padded to 4864.
for (w, h) in [(300u32, 300u32), (512, 600)] {
let before = g.ctx.upload_staging().copies();
for i in 0..4 {
let img = pattern(40 + i, w, h);
let up = g.ctx.upload_image(&img).expect("upload");
let mut e = crate::context::Enc::new(&g.ctx);
let back = e.download(&up).expect("readback");
assert!(back.data.iter().flatten().map(|v| v.to_bits()).eq(img.data.iter().flatten().map(|v| v.to_bits())), "{w}×{h} upload {i} changed pixels");
drop((up, e));
wait();
}
assert!(g.ctx.upload_staging().copies() - before >= 2, "{w}×{h}: no upload went through a reused staging buffer");
assert!(g.ctx.upload_staging().bytes() <= 192 << 20);
}
// A clear with a copy in flight: nothing kept, and later uploads still work.
let img = pattern(7, 300, 300);
let up = g.ctx.upload_image(&img).expect("upload");
g.ctx.upload_staging().clear();
wait();
assert_eq!(g.ctx.upload_staging().bytes(), 0, "a cleared staging kept its buffers");
let mut e = crate::context::Enc::new(&g.ctx);
assert!(e.download(&up).expect("readback").data == img.data);
let again = g.ctx.upload_image(&img).expect("upload after a clear");
assert!(e.download(&again).expect("readback").data == img.data);
}
+264
View File
@@ -0,0 +1,264 @@
//! Footage uploads (#3333): each decoded footage picture uploads once and its texture is shared
//! by every layer and effect input that shows it ([`Pictures`]), and large uploads copy through
//! staging buffers that are kept mapped and reused ([`Staging`]) instead of one that
//! `Queue::write_texture` allocates and frees per upload.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError, Weak};
use effectcraft_raster::Image;
use crate::context::GpuImage;
/// Bytes of picture textures kept after their frame (a sequence's next frame takes the oldest
/// one's texture back from the pool), at most a quarter of the context's upload budget. The
/// newest picture is always kept, whatever its size.
pub(crate) const PICTURE_BUDGET: usize = 256 << 20;
/// Uploads from this size go through [`Staging`]; smaller ones through `Queue::write_texture`.
const STAGING_MIN: u64 = 1 << 20;
/// Bytes of staging buffers that may exist at once (mapped and ready, or in flight).
const STAGING_BUDGET: u64 = 192 << 20;
struct Picture {
img: Weak<Image>,
tex: GpuImage,
bytes: usize,
last_use: u64,
}
/// Uploaded footage pictures by the identity of the decoded frame (its `Arc`): the footage
/// source serves one `Arc` per (file, frame, size, colour curve) while it keeps the frame, and
/// a new one when the file changed, so the same `Arc` means the same pixels. The `Weak` keeps
/// the allocation, so a key cannot be reused by another picture while its entry exists.
#[derive(Default)]
pub(crate) struct Pictures {
map: HashMap<usize, Picture>,
bytes: usize,
clock: u64,
}
impl Pictures {
/// The texture of `img` if it was uploaded and is still kept.
pub(crate) fn get(&mut self, img: &Arc<Image>) -> Option<GpuImage> {
self.clock += 1;
let now = self.clock;
let e = self.map.get_mut(&(Arc::as_ptr(img) as usize))?;
if !e.img.upgrade().is_some_and(|i| Arc::ptr_eq(&i, img)) {
return None;
}
e.last_use = now;
Some(e.tex.clone())
}
/// Keep `tex`, the upload of `img`. Pictures the footage source no longer holds go at once
/// (nothing can ask for them again), then the least recently used over [`PICTURE_BUDGET`],
/// never `img` itself. A dropped texture goes back to the pool once no open encoder can
/// still read it.
pub(crate) fn insert(&mut self, img: &Arc<Image>, tex: GpuImage, budget: usize) {
let key = Arc::as_ptr(img) as usize;
let bytes = img.data.len().saturating_mul(16);
self.clock += 1;
let entry = Picture { img: Arc::downgrade(img), tex, bytes, last_use: self.clock };
if let Some(old) = self.map.insert(key, entry) {
self.bytes = self.bytes.saturating_sub(old.bytes);
}
self.bytes = self.bytes.saturating_add(bytes);
let dead: Vec<usize> = self.map.iter().filter(|(_, e)| e.img.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = self.map.remove(&k) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
while self.bytes > budget {
let Some(k) = self.map.iter().filter(|(k, _)| **k != key).min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break };
if let Some(e) = self.map.remove(&k) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.map.len()
}
pub(crate) fn clear(&mut self) {
self.map.clear();
self.bytes = 0;
}
}
#[derive(Default)]
struct StagingState {
/// Buffers mapped for writing and not in use, each mapped whole.
ready: Vec<wgpu::Buffer>,
/// Bytes of every staging buffer that exists (ready, being mapped or in flight).
total: u64,
/// Raised by [`Staging::clear`]: a buffer of an earlier generation is dropped when its
/// mapping completes.
generation: u64,
/// Uploads copied through a staging buffer (diagnostics, tests).
copies: u64,
}
/// Reused upload staging buffers (`MAP_WRITE | COPY_SRC`). A buffer is written while mapped,
/// unmapped, copied into the texture by a command buffer submitted at once, and mapped again
/// when that submission is done (`CommandEncoder::map_buffer_on_submit`; the callback runs when
/// the device is polled, as every frame does). A new buffer is mapped asynchronously too (never
/// `mapped_at_creation`, which fails hard on drivers that report a failed allocation as an
/// internal error, #591), so the upload that made it, and any upload while no ready buffer fits
/// or the budget is spent, goes through `Queue::write_texture` instead.
#[derive(Default)]
pub(crate) struct Staging {
state: Arc<Mutex<StagingState>>,
}
/// `bytes_per_row` of a staging copy: rows padded to `COPY_BYTES_PER_ROW_ALIGNMENT`.
fn padded_row(width: u32) -> Option<u64> {
let row = u64::from(width).checked_mul(16)?;
let a = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
row.div_ceil(a).checked_mul(a)
}
impl Staging {
fn lock(&self) -> std::sync::MutexGuard<'_, StagingState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Bytes of staging buffers that exist (see [`STAGING_BUDGET`]).
pub(crate) fn bytes(&self) -> u64 {
self.lock().total
}
/// Uploads copied through a staging buffer so far.
#[cfg(test)]
pub(crate) fn copies(&self) -> u64 {
self.lock().copies
}
/// Forget every staging buffer (after a failed frame, #1319): those in flight are dropped
/// when their mapping completes.
pub(crate) fn clear(&self) {
let mut s = self.lock();
s.ready.clear();
// (buffers still in flight are no longer counted: they drop when their mapping ends)
s.total = 0;
s.generation = s.generation.wrapping_add(1);
}
/// The callback of a write mapping of `buffer`: once mapped it is ready for the next
/// upload; it is dropped (and its bytes uncounted) when mapping failed, and dropped when
/// the staging was cleared meanwhile (whose [`Staging::clear`] stopped counting it).
fn on_mapped(&self, buffer: &wgpu::Buffer, generation: u64) -> impl FnOnce(Result<(), wgpu::BufferAsyncError>) + wgpu::WasmNotSend + 'static {
let state = Arc::downgrade(&self.state);
let buffer = buffer.clone();
move |res: Result<(), wgpu::BufferAsyncError>| {
let Some(state) = state.upgrade() else { return };
let mut s = state.lock().unwrap_or_else(PoisonError::into_inner);
if s.generation != generation {
return;
}
if res.is_ok() {
s.ready.push(buffer);
} else {
s.total = s.total.saturating_sub(buffer.size());
}
}
}
/// Copy `img` into `texture` (its size) through a ready staging buffer, recorded and
/// submitted at once: before anything an open encoder records later, as
/// `Queue::write_texture` would. `false`: not done (small, no ready buffer fits, or the
/// mapping failed); the caller writes it through the queue. Starts mapping a new buffer for
/// later uploads when none fits and the budget allows.
pub(crate) fn write(&self, device: &wgpu::Device, queue: &wgpu::Queue, img: &Image, texture: &wgpu::Texture) -> bool {
let (w, h) = (img.width, img.height);
let row = w as usize * 16;
let Some(stride) = padded_row(w) else { return false };
let (Some(need), Ok(stride32)) = (stride.checked_mul(u64::from(h)), u32::try_from(stride)) else { return false };
if need < STAGING_MIN || img.data.len() != w as usize * h as usize {
return false;
}
let (buffer, generation) = {
let mut s = self.lock();
let generation = s.generation;
// The smallest ready buffer that holds it.
let best = s.ready.iter().enumerate().filter(|(_, b)| b.size() >= need).min_by_key(|(_, b)| b.size()).map(|(i, _)| i);
match best {
Some(i) => (Some(s.ready.swap_remove(i)), generation),
None => {
// Ready buffers too small for it make room (a size that no longer occurs).
while s.total.saturating_add(need) > STAGING_BUDGET
&& let Some(b) = s.ready.pop()
{
s.total = s.total.saturating_sub(b.size());
}
if s.total.saturating_add(need) <= STAGING_BUDGET {
s.total += need;
drop(s);
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("upload staging"),
size: need,
usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let done = self.on_mapped(&b, generation);
b.map_async(wgpu::MapMode::Write, .., done);
}
(None, generation)
}
}
};
let Some(buffer) = buffer else { return false };
self.lock().copies += 1;
{
let Ok(mut view) = buffer.get_mapped_range_mut(..need) else {
// Not mapped after all: give it up (its bytes too).
let mut s = self.lock();
if s.generation == generation {
s.total = s.total.saturating_sub(buffer.size());
}
return false;
};
let src: &[u8] = bytemuck::cast_slice(&img.data);
let stride = stride as usize;
if stride == row {
view.slice(..src.len()).copy_from_slice(src);
} else {
for (y, line) in src.chunks_exact(row).enumerate() {
let at = y * stride;
view.slice(at..at + row).copy_from_slice(line);
}
}
}
buffer.unmap();
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("upload") });
enc.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(stride32), rows_per_image: Some(h) },
},
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let done = self.on_mapped(&buffer, generation);
enc.map_buffer_on_submit(&buffer, wgpu::MapMode::Write, .., done);
queue.submit([enc.finish()]);
true
}
}
#[cfg(test)]
mod tests {
use super::padded_row;
#[test]
fn staging_rows_are_padded_to_the_copy_alignment() {
assert_eq!(padded_row(1920), Some(30720));
assert_eq!(padded_row(97), Some(1792));
assert_eq!(padded_row(16), Some(256));
assert_eq!(padded_row(1), Some(256));
}
}
+69 -6
View File
@@ -241,6 +241,9 @@ fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas
if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) {
return done;
}
if let Some(done) = draw_picture(e, r, ctx, layer, canvas, opacity) {
return done;
}
let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) };
if st.plain {
return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity);
@@ -286,14 +289,28 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can
{
return None;
}
let input = r.layer_input(ctx, layer, 0)?;
if input.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_buf(&input) else { return Some(None) };
// The decoded footage picture itself when it is the layer's input (uploaded once per frame
// and shared with every layer that shows it), else the cached input buffer.
let (src, offset, scale) = match picture(r, ctx, layer) {
Some(p) => {
if p.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) };
(src, p.effect_offset, p.scale)
}
None => {
let input = r.layer_input(ctx, layer, 0)?;
if input.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_buf(&input) else { return Some(None) };
(src, input.offset, input.scale)
}
};
let pipe = r.pipe();
// `layer_input` is in effect space: run the stack there, then back to layer space.
let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels);
let mut fx = crate::effects::GpuFx::new(e, src, offset, scale, pipe.levels);
let origin = r.run_layer_effects_on(ctx, layer, &mut fx);
let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) };
offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale];
@@ -308,6 +325,52 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can
Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c))
}
/// Shared footage pictures ([`picture`]); tests turn it off to compare.
pub(crate) static SHARED_PICTURES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// The layer's source as the decoded footage picture (`Renderer::footage_picture`), unless
/// sharing is off or per-layer timings are being recorded (those time the layer-cache path).
fn picture(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_render::FootagePicture> {
if !SHARED_PICTURES.load(std::sync::atomic::Ordering::Relaxed) || r.profile.is_some() {
return None;
}
r.footage_picture(ctx, layer)
}
/// A footage layer without effects or layer styles whose source is the decoded picture as it
/// is (`Renderer::footage_picture`) and needs no conversion to the blending space: placed
/// straight from the picture's texture, uploaded once per frame and shared by every layer that
/// shows the same frame, instead of from a copy per layer in the layer cache, uploaded per
/// layer. The pixels are the same: the layer-cache buffer is that picture, copied. `None`: not
/// this kind of layer; `Some(None)`: the GPU failed.
fn draw_picture(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option<Option<()>> {
if (layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty())) || styles::active(ctx, layer) || r.pipe().to_blend().is_some() {
return None;
}
let p = picture(r, ctx, layer)?;
if p.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) };
// The placement only reads the buffer's size, offset and scale.
let shape = Buf { img: Image { width: p.img.width, height: p.img.height, data: Vec::new() }, offset: [0.0; 2], scale: p.scale };
Some(composite_src(e, r, ctx, layer, &shape, src, canvas, opacity))
}
/// [`composite_layer`] of a GPU image `src` shaped like `shape`.
#[allow(clippy::too_many_arguments)]
fn composite_src(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, shape: &Buf, src: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
if opacity <= 0.0 {
return Some(());
}
if r.track_matte(ctx, layer).is_none() && !layer.preserve_transparency {
*canvas = place_src(e, r, ctx, layer, shape, src, canvas, layer.blend_mode, opacity, r.pipe().levels)?;
return Some(());
}
let iso = place_src(e, r, ctx, layer, shape, src, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
}
/// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level
/// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source`
/// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its
+5
View File
@@ -230,6 +230,11 @@ impl LayerCache {
/// used. (A render that started before a drop may publish its buffer after the drop's
/// purge, Reload Footage clearing the cache while the buffer was being written: the check
/// here, not the order of the two, keeps it from being served.)
/// Whether a buffer is kept in memory under `key` (a peek: no hit, no miss, no disk read).
pub(crate) fn holds(&self, key: u64) -> bool {
self.kept(key, false).is_some()
}
fn kept(&self, key: u64, hit: bool) -> Option<(Arc<Buf>, Arc<ReadFiles>)> {
let found = {
let mut g = self.inner.lock().ok()?;
+1 -1
View File
@@ -61,7 +61,7 @@ pub use layers::{Placement, StyledLayer};
pub(crate) use opts::Collapse3d;
pub use opts::{LayerTiming, RenderOpts};
use opts::{MAX_THROUGH, Through};
pub use pipeline::{Collapsed, MediaOverride, PropertyOverride};
pub use pipeline::{Collapsed, FootagePicture, MediaOverride, PropertyOverride};
pub struct Renderer<'a> {
pub project: &'a Project,
+95 -14
View File
@@ -19,6 +19,24 @@ pub(crate) struct EffectStack {
pub origin: [f64; 2],
}
/// A footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]):
/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what
/// [`Renderer::layer_input`] gives) at `effect_offset`.
#[derive(Clone, Debug)]
pub struct FootagePicture {
pub img: Arc<Image>,
pub scale: f64,
pub effect_offset: [f64; 2],
}
/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]).
enum FootageLayout {
/// Its own pixels, at this buffer scale.
Native { scale: f64 },
/// Resampled to `width`×`height` at this buffer scale.
Resample { width: u32, height: u32, scale: f64 },
}
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
#[derive(Clone, Debug)]
pub struct PropertyOverride {
@@ -861,12 +879,7 @@ impl<'a> Renderer<'a> {
return Some(buf);
}
}
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
// (the font files go to this render's log, which `ctx` may not carry: see
@@ -898,14 +911,9 @@ impl<'a> Renderer<'a> {
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
/// nominal size; proxies are scaled up to it), converted from its colour profile.
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
let mut buf = match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s },
Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s },
_ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k },
let mut buf = match self.footage_layout(img, w, h) {
FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale },
FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale },
};
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
@@ -913,6 +921,79 @@ impl<'a> Renderer<'a> {
buf
}
/// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer
/// pixels: its own pixels, or resampled.
fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s },
Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s },
_ => FootageLayout::Native { scale: 1.0 / k },
}
}
/// A footage layer's decoded frame at the context time (the proxy's when one is used) and
/// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of.
fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc<Image>, &'a Footage)> {
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
Some((img, pf))
}
/// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a
/// footage layer (not continuously rasterised vector footage) without masks whose frame
/// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer
/// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and
/// `scale`.
/// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so
/// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is
/// made the usual way (any of the above applies, or the frame is missing), and also when the
/// frame is made per call rather than served by the footage source (frame blending,
/// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new
/// picture each time, nothing to share) or when the layer cache already holds the layer's
/// buffer (served from memory as before, without asking the footage source).
pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option<FootagePicture> {
let LayerSource::Footage { item } = &layer.source else { return None };
if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off {
return None;
}
let item = ctx.media_item(layer, *item);
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) {
return None;
}
let pf = self.proxy_for(item).unwrap_or(f);
if self.pipe.media_in(pf).is_some()
|| pf.fields != effectcraft_project::FieldOrder::Off
|| pf.invert_alpha
|| pf.linear_light_applies()
|| pf.cineon_applies()
{
return None;
}
if let Some(cache) = self.cache {
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0));
if [content, input].into_iter().flatten().any(|k| cache.holds(k)) {
return None;
}
}
let (img, _) = self.footage_picture_of(ctx, layer, item, f)?;
let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None };
// `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin).
let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale };
at.rebase(ctx.effect_bounds(layer).1);
Some(FootagePicture { img, scale, effect_offset: at.offset })
}
/// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage
/// is resampled to it (below 3/4 scale).
fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> {