mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
Merge pull request #5332 from vistaero/codex/gpu-viewport-streaming
Stream GPU viewport pages with bounded staging and cancellation
This commit is contained in:
17 files changed
+1626
-17
No files matched your search
@@ -33,6 +33,7 @@ pub mod proxy;
|
||||
pub mod psblend;
|
||||
pub mod shape_split;
|
||||
mod uniform;
|
||||
pub mod viewport;
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern};
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::content::RENDER_TILE;
|
||||
use super::ctx::Ctx;
|
||||
use super::render::{band_rows_for, render, render_bands, render_tiled_with};
|
||||
use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform};
|
||||
use photocraft_raster::Interrupt;
|
||||
|
||||
/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is
|
||||
/// faithful; smaller ones are reduced from the exact composite.
|
||||
@@ -101,23 +102,66 @@ pub(crate) fn render_reduced_with(
|
||||
uniform: bool,
|
||||
stats: &mut ReducedStats,
|
||||
) -> Buffer {
|
||||
match render_reduced_cells(doc, b, Cells::Fit(w, h), damage, band_rows, uniform, stats, Interrupt::NONE) {
|
||||
Ok(buf) => buf,
|
||||
// Only an interrupt fails a reduction, and this one has none.
|
||||
Err(_) => Buffer::transparent(Rect::EMPTY),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a reduced render maps document pixels of its area to output pixels.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Cells {
|
||||
/// `w`×`h` output pixels spread evenly over the area ([`render_reduced`]).
|
||||
Fit(u32, u32),
|
||||
/// Output pixel `t` covers the area's pixels `[t·k, (t+1)·k)`, clipped to the area: a
|
||||
/// globally aligned reduction by `k` when the area starts on a multiple of `k`
|
||||
/// ([`crate::viewport::render_page`]).
|
||||
Factor(u32),
|
||||
}
|
||||
|
||||
/// [`render_reduced_with`] on a cell grid, checking `interrupt` between bands.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn render_reduced_cells(
|
||||
doc: &Document,
|
||||
b: Rect,
|
||||
cells: Cells,
|
||||
damage: Option<Rect>,
|
||||
band_rows: i32,
|
||||
uniform: bool,
|
||||
stats: &mut ReducedStats,
|
||||
interrupt: Interrupt<'_>,
|
||||
) -> Result<Buffer, String> {
|
||||
let (fw, fh) = (b.width() as usize, b.height() as usize);
|
||||
let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize);
|
||||
let (w, h, k) = match cells {
|
||||
Cells::Fit(w, h) => (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize, None),
|
||||
Cells::Factor(k) => {
|
||||
let k = k.max(1);
|
||||
(b.width().div_ceil(k).max(1) as usize, b.height().div_ceil(k).max(1) as usize, Some(k as usize))
|
||||
}
|
||||
};
|
||||
let full = Rect::from_xywh(0, 0, w as u32, h as u32);
|
||||
if fw == 0 || fh == 0 {
|
||||
return Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full });
|
||||
return Ok(Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full }));
|
||||
}
|
||||
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n).
|
||||
let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
|
||||
// Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n)
|
||||
// (or [t·k, (t+1)·k) on a factor grid).
|
||||
let span = |t: usize, f: usize, n: usize| match k {
|
||||
Some(k) => ((t * k).min(f), ((t + 1) * k).min(f)),
|
||||
None => (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)),
|
||||
};
|
||||
// The output pixel whose span holds document column / row `x` (spans are contiguous: n <= f).
|
||||
let cell = |x: usize, f: usize, n: usize| ((x + 1) * n).saturating_sub(1) / f;
|
||||
let cell = |x: usize, f: usize, n: usize| match k {
|
||||
Some(k) => x / k,
|
||||
None => ((x + 1) * n).saturating_sub(1) / f,
|
||||
};
|
||||
// Output pixels to produce and the document area they cover.
|
||||
let out = match damage {
|
||||
None => full,
|
||||
Some(d) => {
|
||||
let d = d.intersect(&b);
|
||||
if d.is_empty() {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
return Ok(Buffer::transparent(Rect::EMPTY));
|
||||
}
|
||||
let (x0, x1) = ((d.x0 - b.x0) as usize, (d.x1 - b.x0) as usize);
|
||||
let (y0, y1) = ((d.y0 - b.y0) as usize, (d.y1 - b.y0) as usize);
|
||||
@@ -131,7 +175,7 @@ pub(crate) fn render_reduced_with(
|
||||
if (w, h) == (fw, fh) {
|
||||
let mut px = render(doc, src);
|
||||
px.rect = out;
|
||||
return px;
|
||||
return Ok(px);
|
||||
}
|
||||
let map = |f: usize, n: usize| {
|
||||
let mut m = vec![0u32; f];
|
||||
@@ -200,6 +244,7 @@ pub(crate) fn render_reduced_with(
|
||||
let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize;
|
||||
let mut y = grid(sy0, b.y0);
|
||||
while y < sy1 as i64 {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
let y1 = (y + band_h as i64).min(sy1 as i64);
|
||||
let tiles: Vec<(Rect, Rect)> = (y..y1)
|
||||
.step_by(t)
|
||||
@@ -255,7 +300,8 @@ pub(crate) fn render_reduced_with(
|
||||
y = y1;
|
||||
}
|
||||
} else {
|
||||
let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| {
|
||||
render_bands(doc, src, band_rows, |band| -> Result<(), String> {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
stats.composited += band.px.len() as u64;
|
||||
let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize);
|
||||
let sw = sx1 - sx0;
|
||||
@@ -265,8 +311,7 @@ pub(crate) fn render_reduced_with(
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
debug_assert!(ok.is_ok());
|
||||
})?;
|
||||
}
|
||||
let col_w: Vec<usize> = (0..ow)
|
||||
.map(|x| {
|
||||
@@ -296,5 +341,6 @@ pub(crate) fn render_reduced_with(
|
||||
for (row, out) in acc.chunks_mut(ow).enumerate() {
|
||||
norm_row(row, out);
|
||||
}
|
||||
Buffer { rect: out, px: acc }
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(Buffer { rect: out, px: acc })
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Bounded, cancellable viewport pages. Reduction is globally aligned, so independently
|
||||
//! rendered pages have exactly the same pixel footprints at their shared edges.
|
||||
use crate::{Buffer, render, render_bands};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
|
||||
/// A quick, explicitly provisional reduction. Only the requested source rows are read; the
|
||||
/// exact composite below replaces it after visible pages have first become available.
|
||||
pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
|
||||
if factor <= 1 || !crate::proxy::proxy_faithful(doc) {
|
||||
return render_page(doc, source, factor, interrupt);
|
||||
}
|
||||
let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?;
|
||||
let source = source.intersect(&doc.bounds());
|
||||
if source.is_empty() {
|
||||
return Ok(Buffer::transparent(Rect::EMPTY));
|
||||
}
|
||||
let rect = Rect::new(
|
||||
source.x0 / k,
|
||||
source.y0 / k,
|
||||
source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0),
|
||||
source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0),
|
||||
);
|
||||
if rect.width() > 1024 || rect.height() > 1024 {
|
||||
return Err("viewport page is too large".into());
|
||||
}
|
||||
fn shrink(s: &Surface, r: Rect, k: i32, i: Interrupt<'_>) -> Result<Surface, String> {
|
||||
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
||||
let bpp = s.format().bytes_per_pixel();
|
||||
for y in r.y0..r.y1 {
|
||||
i.check().map_err(|e| e.to_string())?;
|
||||
let sy = y.saturating_mul(k).saturating_add(k / 2);
|
||||
let row = s.to_interleaved(Rect::new(r.x0.saturating_mul(k), sy, r.x1.saturating_mul(k), sy.saturating_add(1)));
|
||||
let mut reduced = Vec::with_capacity(r.width() as usize * bpp);
|
||||
for x in 0..r.width() as usize {
|
||||
let offset = (x * k as usize + k as usize / 2) * bpp;
|
||||
reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?);
|
||||
}
|
||||
out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize) -> Result<(), String> {
|
||||
if depth > 256 {
|
||||
return Err("viewport layer nesting limit exceeded".into());
|
||||
}
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shrink(&m.surface, r, k, i)?;
|
||||
}
|
||||
if let Some(c) = &mut l.fill_cache {
|
||||
c.surface = shrink(&c.surface, r, k, i)?;
|
||||
}
|
||||
use photocraft_doc::LayerContent;
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = shrink(s, r, k, i)?,
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(a) = &mut g.artboard {
|
||||
a.rect = Rect::new(a.rect.x0.div_euclid(k), a.rect.y0.div_euclid(k), a.rect.x1.div_euclid(k), a.rect.y1.div_euclid(k));
|
||||
}
|
||||
for c in &mut g.children {
|
||||
layer(c, r, k, i, depth + 1)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Shape(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut p = doc.clone();
|
||||
p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor));
|
||||
for l in &mut p.layers {
|
||||
layer(l, rect, k, interrupt, 0)?;
|
||||
}
|
||||
for c in &mut p.channels {
|
||||
c.surface = shrink(&c.surface, rect, k, interrupt)?;
|
||||
}
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(render(&p, rect))
|
||||
}
|
||||
|
||||
pub fn render_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
let source = source.intersect(&doc.bounds());
|
||||
if source.is_empty() {
|
||||
return Ok(Buffer::transparent(Rect::EMPTY));
|
||||
}
|
||||
let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?;
|
||||
let out = Rect::new(
|
||||
source.x0.div_euclid(k),
|
||||
source.y0.div_euclid(k),
|
||||
source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0),
|
||||
source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0),
|
||||
);
|
||||
if out.width() > 1024 || out.height() > 1024 {
|
||||
return Err("viewport page is too large".into());
|
||||
}
|
||||
if k == 1 {
|
||||
return Ok(render(doc, source));
|
||||
}
|
||||
if source.x0.rem_euclid(k) != 0 || source.y0.rem_euclid(k) != 0 {
|
||||
return average_page(doc, source, k, out, interrupt);
|
||||
}
|
||||
// The reduced-render path the CPU canvas and navigator use: one-colour document tiles are
|
||||
// composited once instead of per pixel, and the rest in parallel tiles, then area-averaged
|
||||
// into cells aligned to multiples of `k` (the same cells as [`average_page`]).
|
||||
let stats = &mut crate::reduced::ReducedStats::default();
|
||||
let mut buf = crate::reduced::render_reduced_cells(doc, source, crate::reduced::Cells::Factor(factor), None, 128, true, stats, interrupt)?;
|
||||
if buf.rect.width() != out.width() || buf.rect.height() != out.height() {
|
||||
return Err("invalid viewport reduction".into());
|
||||
}
|
||||
buf.rect = out;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// [`render_page`] by compositing every source pixel and averaging each `k`×`k` footprint (also
|
||||
/// the reference its reduced path is tested against).
|
||||
fn average_page(doc: &Document, source: Rect, k: i32, out: Rect, interrupt: Interrupt<'_>) -> Result<Buffer, String> {
|
||||
let factor = k.unsigned_abs();
|
||||
let mut result = Buffer::transparent(out);
|
||||
render_bands(doc, source, 128, |band| -> Result<(), String> {
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
for (i, p) in band.px.iter().enumerate() {
|
||||
let x = band.rect.x0 + (i % band.rect.width() as usize) as i32;
|
||||
let y = band.rect.y0 + (i / band.rect.width() as usize) as i32;
|
||||
let offset = (y.div_euclid(k) - out.y0) as usize * out.width() as usize + (x.div_euclid(k) - out.x0) as usize;
|
||||
let q = result.px.get_mut(offset).ok_or("invalid viewport accumulator")?;
|
||||
for (a, b) in q.iter_mut().take(3).zip(p.iter()) {
|
||||
*a += *b * p.get(3).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(a) = q.get_mut(3) {
|
||||
*a += p.get(3).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
for (i, p) in result.px.iter_mut().enumerate() {
|
||||
let x = out.x0 + (i % out.width() as usize) as i32;
|
||||
let y = out.y0 + (i / out.width() as usize) as i32;
|
||||
let footprint = Rect::from_xywh(x.saturating_mul(k), y.saturating_mul(k), factor, factor).intersect(&source);
|
||||
let alpha = p.get(3).copied().unwrap_or(0.0);
|
||||
if alpha > 0.0 {
|
||||
for c in p.iter_mut().take(3) {
|
||||
*c /= alpha;
|
||||
}
|
||||
if let Some(a) = p.get_mut(3) {
|
||||
*a /= (u64::from(footprint.width()) * u64::from(footprint.height())).max(1) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
#[test]
|
||||
fn high_zoom_is_original_resolution_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let r = Rect::new(65001, 22003, 65513, 22515);
|
||||
let mut d = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, depth);
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
s.fill_rect(r, &[1.0; 4]);
|
||||
d.layers.push(photocraft_doc::Layer::new("region", photocraft_doc::LayerContent::Raster(s)));
|
||||
let p = render_page(&d, r, 1, Interrupt::NONE).unwrap();
|
||||
assert_eq!(p.rect, r);
|
||||
assert_eq!(p.px, render(&d, r).px);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn independent_pages_match_whole_reduction_and_odd_edges() {
|
||||
let d = Document::with_background("d", Size::new(53, 31), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let whole = render_page(&d, d.bounds(), 4, Interrupt::NONE).unwrap();
|
||||
for r in [Rect::new(0, 0, 28, 31), Rect::new(28, 0, 53, 31)] {
|
||||
let p = render_page(&d, r, 4, Interrupt::NONE).unwrap();
|
||||
for y in p.rect.y0..p.rect.y1 {
|
||||
for x in p.rect.x0..p.rect.x1 {
|
||||
assert_eq!(
|
||||
p.px[((y - p.rect.y0) * p.rect.width() as i32 + x - p.rect.x0) as usize],
|
||||
whole.px[(y * whole.rect.width() as i32 + x) as usize]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour
|
||||
/// tiles, so the reduced path takes both its one-colour and per-pixel branches.
|
||||
fn busy(depth: SampleType) -> Document {
|
||||
let mut d = Document::with_background("busy", Size::new(1100, 700), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed % 1000) as f32 / 999.0
|
||||
};
|
||||
for y in (100..530).step_by(3) {
|
||||
for x in (190..905).step_by(3) {
|
||||
let px = [next(), next(), next(), next()];
|
||||
s.fill_rect(Rect::from_xywh(x, y, 3, 3), &px);
|
||||
}
|
||||
}
|
||||
d.layers.push(photocraft_doc::Layer::new("noise", photocraft_doc::LayerContent::Raster(s)));
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
s.fill_rect(Rect::new(400, 0, 1100, 650), &[0.2, 0.5, 0.9, 1.0]);
|
||||
let mut top = photocraft_doc::Layer::new("tint", photocraft_doc::LayerContent::Raster(s));
|
||||
top.opacity = 0.6;
|
||||
top.blend = photocraft_color::blend::BlendMode::Multiply;
|
||||
d.layers.push(top);
|
||||
d
|
||||
}
|
||||
#[test]
|
||||
fn reduced_pages_match_averaging_every_composited_pixel() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let d = busy(depth);
|
||||
for k in [2u32, 4, 16] {
|
||||
let ki = k as i32;
|
||||
for source in [d.bounds(), Rect::new(512, 256, 1024, 700), Rect::new(ki * 13, ki * 5, 1100, ki * 37)] {
|
||||
let source = source.intersect(&d.bounds());
|
||||
let out = Rect::new(source.x0 / ki, source.y0 / ki, (source.x1 + ki - 1) / ki, (source.y1 + ki - 1) / ki);
|
||||
let reference = average_page(&d, source, ki, out, Interrupt::NONE).unwrap();
|
||||
let got = render_page(&d, source, k, Interrupt::NONE).unwrap();
|
||||
assert_eq!(got.rect, reference.rect, "{depth:?} k={k} {source:?}");
|
||||
let worst = got.px.iter().zip(&reference.px).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-4, "{depth:?} k={k} {source:?}: differs by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_invalid_sizes_and_honors_cancellation() {
|
||||
let d = Document::new("d", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8);
|
||||
assert!(render_page(&d, d.bounds(), 1, Interrupt::NONE).is_err());
|
||||
assert!(render_page(&d, d.bounds(), 0, Interrupt::NONE).is_err());
|
||||
assert!(render_page(&d, Rect::new(0, 0, 512, 512), 1, Interrupt::cancel_only(&|| true)).is_err());
|
||||
// Cancelled after the first band of the reduced path.
|
||||
let checks = std::sync::atomic::AtomicUsize::new(0);
|
||||
let later = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 2;
|
||||
assert!(render_page(&d, Rect::new(0, 0, 4096, 4096), 8, Interrupt::cancel_only(&later)).is_err());
|
||||
assert!(checks.load(std::sync::atomic::Ordering::Relaxed) >= 3);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ impl PhotocraftApp {
|
||||
display_memo: HashMap::new(),
|
||||
navigator_gpu: HashMap::new(),
|
||||
navigator_plain: std::collections::HashSet::new(),
|
||||
streams: HashMap::new(),
|
||||
info_samplers: None,
|
||||
monitors: Default::default(),
|
||||
checker: None,
|
||||
|
||||
@@ -346,6 +346,8 @@ impl PhotocraftApp {
|
||||
}
|
||||
self.canvases.retain(|(doc, _), _| ids.contains(doc));
|
||||
self.navigator_textures.retain(|doc, _| ids.contains(doc));
|
||||
// Closing a document cancels its page work; its GPU pages go with `retain_gpu_documents`.
|
||||
self.streams.retain(|doc, _| ids.contains(doc));
|
||||
self.view_docs = ids;
|
||||
self.prune_thumbs();
|
||||
self.sync_mask_targets();
|
||||
|
||||
@@ -53,6 +53,7 @@ pub mod tool_feedback;
|
||||
mod tool_input;
|
||||
mod transform_controls;
|
||||
mod view_xform;
|
||||
pub(crate) mod viewport_pages;
|
||||
pub mod wheel_nav;
|
||||
pub mod zoom_field;
|
||||
pub mod zoom_hold;
|
||||
@@ -65,7 +66,6 @@ pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_c
|
||||
use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair};
|
||||
#[cfg(test)]
|
||||
pub(crate) use display::GPU_OUTPUT;
|
||||
#[cfg(test)]
|
||||
use display::texture_key;
|
||||
pub(crate) use display::{cache_key, canvas_display, display_doc, display_image};
|
||||
use display::{checker, hdr_preview, sync_display_lut};
|
||||
@@ -418,6 +418,29 @@ fn paint_canvas(
|
||||
offset: [0.0, 0.0],
|
||||
};
|
||||
paint_gpu_view(painter, rect, params, &pattern);
|
||||
} else if gpu_ok && viewport_pages::show(app, ctx, idx, &xf, view.zoom) {
|
||||
// Too large for the document's texture set: the pages in view, rendered in the background.
|
||||
on_gpu = true;
|
||||
app.perf.gpu = true;
|
||||
let key = viewport_pages::stream_key(doc.id.0);
|
||||
let params = crate::gpu_canvas::ViewParams {
|
||||
doc: key,
|
||||
doc_size: [doc.size.width, doc.size.height],
|
||||
zoom: point_zoom,
|
||||
center: xf.center,
|
||||
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
|
||||
shadow: {
|
||||
let t = crate::theme::Tokens::get(ctx);
|
||||
!t.bevel && !t.pro && drop_shadow
|
||||
},
|
||||
pixel_grid,
|
||||
view_key: egui::Id::unique(("pc-canvas-pages", ctx.viewport_id(), doc.id.0)).value(),
|
||||
display: sync_display_lut(app, doc, key, output),
|
||||
output: output.unwrap_or(0),
|
||||
hdr: hdr_preview(app, doc),
|
||||
offset: [0.0, 0.0],
|
||||
};
|
||||
paint_gpu_view(painter, rect, params, &pattern);
|
||||
} else if gpu_ok && ensure_gpu(app, ctx, idx, redraw_focus(&target_xf, &xf)) {
|
||||
on_gpu = true;
|
||||
app.perf.gpu = true;
|
||||
|
||||
@@ -612,6 +612,90 @@ fn a_canvas_over_the_texture_budget_records_its_reason() {
|
||||
assert_eq!(g.harness.state().render_path.history().filter(|c| c.doc == doc).count(), 1, "once, not every frame");
|
||||
}
|
||||
|
||||
/// Frames until the active document's viewport pages are all rendered and uploaded.
|
||||
fn settle_pages(g: &mut Gpu) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
loop {
|
||||
g.harness.step();
|
||||
let app = g.harness.state();
|
||||
let id = app.session.active().expect("document").doc.id;
|
||||
if app.streams.get(&id).is_some_and(|s| !s.busy() && s.stats.wanted > 0 && s.stats.ready == s.stats.wanted) {
|
||||
return;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "the viewport pages didn't finish");
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen pixel at the window's centre.
|
||||
fn centre_pixel(g: &mut Gpu) -> [u8; 4] {
|
||||
let img = g.harness.render().expect("render");
|
||||
let p = img.get_pixel(img.width() / 2, img.height() / 2);
|
||||
p.0
|
||||
}
|
||||
|
||||
/// #5332: a document whose texture set doesn't fit the canvas budget stays on the GPU canvas,
|
||||
/// drawn from the pages in view instead of the CPU canvas, with the same pixels; an edit
|
||||
/// re-renders only the pages, and closing it frees them.
|
||||
#[test]
|
||||
fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Some(mut g) = gpu_app() else { return };
|
||||
g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new");
|
||||
g.harness.run_steps(3);
|
||||
assert!(on_gpu_canvas(&g));
|
||||
let whole = centre_pixel(&mut g);
|
||||
let gpu = g.harness.state().gpu.clone().expect("gpu canvas");
|
||||
let doc = g.harness.state().session.active().expect("document").doc.clone();
|
||||
let key = crate::canvas::viewport_pages::stream_key(doc.id.0);
|
||||
// Room for pages (half the budget), not for the document's texture set.
|
||||
let (set, _) = gpu.canvas_fit(&doc).expect("fit");
|
||||
gpu.set_canvas_budget(set - 1);
|
||||
settle_pages(&mut g);
|
||||
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
|
||||
assert!(gpu.texture_info(doc.id.0).is_none(), "no texture set");
|
||||
let (pages, bytes) = gpu.pages(key);
|
||||
let stats = g.harness.state().streams[&doc.id].stats.clone();
|
||||
assert_eq!(pages.len(), stats.resident);
|
||||
assert!(stats.wanted > 0 && stats.last_error.is_none(), "{stats:?}");
|
||||
assert!(bytes <= (set - 1) / 2, "pages stay within half the budget: {bytes}");
|
||||
let shown = centre_pixel(&mut g);
|
||||
assert!((0..3).all(|i| shown[i].abs_diff(whole[i]) <= 2), "pages {shown:?}, texture set {whole:?}");
|
||||
// An edit re-renders the pages it reaches.
|
||||
let uploads = stats.uploads;
|
||||
g.harness.state_mut().run("edit.fill", json!({"color": "#cc8844"})).expect("fill");
|
||||
settle_pages(&mut g);
|
||||
let stats = g.harness.state().streams[&doc.id].stats.clone();
|
||||
assert!(stats.dirtied > 0 && stats.uploads > uploads, "{stats:?}");
|
||||
let filled = centre_pixel(&mut g);
|
||||
assert!(filled[0] > 0xb0 && filled[2] < 0x60, "the fill is shown: {filled:?}");
|
||||
// A small edit re-renders only the part of each page it reaches, into the page held.
|
||||
let (held, partial) = (gpu.pages(key).0.len(), stats.partial);
|
||||
// (A frame between the steps, as in use: the edit's damage is known one revision at a time.)
|
||||
g.harness.state_mut().run("select.rect", json!({"x": 1400, "y": 900, "width": 200, "height": 200})).expect("select");
|
||||
settle_pages(&mut g);
|
||||
g.harness.state_mut().run("edit.fill", json!({"color": "#22aa22"})).expect("small fill");
|
||||
settle_pages(&mut g);
|
||||
let stats = g.harness.state().streams[&doc.id].stats.clone();
|
||||
assert!(stats.partial > partial, "{stats:?}");
|
||||
assert_eq!(gpu.pages(key).0.len(), held, "no new page textures");
|
||||
// Just its square is shown: 200 px at about 20% zoom, some 40 screen pixels a side.
|
||||
let img = g.harness.render().expect("render");
|
||||
let green = img.pixels().filter(|p| p.0[1] > 0x90 && p.0[0] < 0x50 && p.0[2] < 0x50).count();
|
||||
assert!((1000..2500).contains(&green), "the small fill is shown: {green} pixels");
|
||||
// A budget too small for one page draws it on the CPU canvas, as before.
|
||||
gpu.set_canvas_budget(1);
|
||||
g.harness.run_steps(3);
|
||||
assert_eq!(render_path_of(&g), (Some("canvasTooLarge"), Some("CPU Rendering")));
|
||||
gpu.set_canvas_budget(set - 1);
|
||||
settle_pages(&mut g);
|
||||
// Closing the document cancels its work and frees its pages.
|
||||
g.harness.state_mut().run("file.close", json!({"save": false})).expect("close");
|
||||
g.harness.run_steps(3);
|
||||
assert!(g.harness.state().streams.is_empty());
|
||||
assert!(gpu.pages(key).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flipped_view_records_why_it_draws_on_the_cpu() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
|
||||
@@ -34,11 +34,13 @@ use eframe::wgpu;
|
||||
|
||||
use crate::render_path::CpuReason;
|
||||
|
||||
pub use pages::{MAX_PAGE_SIDE, PageUpload};
|
||||
pub use photocraft_gpu::{DeviceHealth, Fault};
|
||||
|
||||
mod budget;
|
||||
mod callback;
|
||||
mod doc_textures;
|
||||
mod pages;
|
||||
mod perf;
|
||||
mod resources;
|
||||
mod setup;
|
||||
@@ -93,7 +95,7 @@ const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const F16_BUDGET_PX: u64 = 100_000_000;
|
||||
const VIEW_UNIFORM_SIZE: u64 = 128;
|
||||
const VIEW_FLOATS: usize = 32;
|
||||
const TILE_UNIFORM_SIZE: u64 = 16;
|
||||
const TILE_UNIFORM_SIZE: u64 = 32;
|
||||
|
||||
/// Whether this view can use the GPU canvas. Flip and non-square pixel aspect correction are
|
||||
/// still CPU blits (the shader's filtering assumes square pixels); rotation is a shader uniform.
|
||||
|
||||
@@ -35,6 +35,15 @@ pub(super) fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], u
|
||||
buffer
|
||||
}
|
||||
|
||||
/// A tile's uniform: its document rect (x, y, w, h) and the exclusive document bounds it draws
|
||||
/// (x0, y0, x1, y1). A whole-document tile draws all its quad covers (no bound, so rounding at a
|
||||
/// shared edge cannot open a seam); a viewport page only its core, so the one-texel apron around
|
||||
/// it filters its edge without being drawn twice.
|
||||
pub(super) fn tile_uniform([x, y, w, h]: [u32; 4], core: Option<[i32; 4]>) -> [f32; 8] {
|
||||
let [cx0, cy0, cx1, cy1] = core.map_or([-1e30, -1e30, 1e30, 1e30], |c| c.map(|v| v as f32));
|
||||
[x as f32, y as f32, w as f32, h as f32, cx0, cy0, cx1, cy1]
|
||||
}
|
||||
|
||||
impl DocTextures {
|
||||
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
|
||||
/// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB
|
||||
@@ -87,7 +96,7 @@ impl DocTextures {
|
||||
.map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() }))
|
||||
.collect();
|
||||
let full = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), wgpu::BufferUsages::UNIFORM);
|
||||
let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&tile_uniform([tx, ty, w, h], None)), wgpu::BufferUsages::UNIFORM);
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_canvas_tile"),
|
||||
layout: &res.tile_bgl,
|
||||
@@ -96,7 +105,7 @@ impl DocTextures {
|
||||
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform });
|
||||
tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform, page: None });
|
||||
}
|
||||
}
|
||||
Self { size, format, tiles }
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Viewport pages of a document too large for its whole-document texture set (see
|
||||
//! `canvas::viewport_pages`): independent single-level textures at their own reduction, drawn
|
||||
//! by the same tile pipeline. They live under their own key, never next to a texture set.
|
||||
|
||||
use super::doc_textures::{init_buffer, tile_uniform};
|
||||
use super::resources::{DocTextures, Resources, Tile, f32_bytes};
|
||||
use super::texels::{texel_bytes, texels};
|
||||
use super::{GpuCanvas, Ordering, wgpu};
|
||||
use photocraft_geom::Rect as DRect;
|
||||
|
||||
/// The largest page texture side [`GpuCanvas::upload_page`] accepts (a 512 px page and its apron).
|
||||
pub const MAX_PAGE_SIDE: u32 = 1024;
|
||||
|
||||
/// One page to store: `buf` (document pixels reduced by `factor`, its rect in reduced pixels)
|
||||
/// drawn over document area `core` of the `size` document `key`.
|
||||
pub struct PageUpload<'a> {
|
||||
pub key: u64,
|
||||
pub page: u64,
|
||||
pub size: [u32; 2],
|
||||
pub depth: photocraft_doc::SampleType,
|
||||
pub factor: u32,
|
||||
pub core: DRect,
|
||||
pub buf: &'a photocraft_compose::Buffer,
|
||||
}
|
||||
|
||||
impl DocTextures {
|
||||
/// GPU bytes held by this set's textures (pages have one level, texture sets their mips).
|
||||
pub(super) fn page_bytes(&self) -> u64 {
|
||||
let bpp = texel_bytes(self.format);
|
||||
self.tiles.iter().filter(|t| t.page.is_some()).map(|t| u64::from(t.texture.width()) * u64::from(t.texture.height()) * bpp).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuCanvas {
|
||||
/// The GPU bytes one page of `w`×`h` texels takes for a `depth` document of `size`.
|
||||
pub fn page_texture_bytes(&self, depth: photocraft_doc::SampleType, size: [u32; 2], w: u32, h: u32) -> u64 {
|
||||
u64::from(w) * u64::from(h) * texel_bytes(self.format_for(depth, size))
|
||||
}
|
||||
|
||||
/// Store (or replace) page `page` of `key`. False, with nothing changed, when the device is
|
||||
/// not healthy, the page is malformed, or the pages of `key` would exceed the canvas budget
|
||||
/// (the caller evicts its cold pages first; this is the backstop).
|
||||
/// Writes `buf` (texels at the page's reduction, a part of it) into the page `page` of `key`
|
||||
/// already resident: an edit's re-render, without a new texture. False when that page isn't
|
||||
/// held or `buf` falls outside it.
|
||||
pub fn update_page(&self, key: u64, page: u64, buf: &photocraft_compose::Buffer) -> bool {
|
||||
let (w, h) = (buf.rect.width(), buf.rect.height());
|
||||
if !self.health.is_ok() || w == 0 || h == 0 || buf.px.len() as u64 != u64::from(w) * u64::from(h) {
|
||||
return false;
|
||||
}
|
||||
let renderer = self.rs.renderer.read();
|
||||
let Some(res) = renderer.callback_resources.get::<Resources>() else { return false };
|
||||
let Some(tile) = res.docs.get(&key).and_then(|d| d.tiles.iter().find(|t| t.page.is_some_and(|(id, _)| id == page))) else {
|
||||
return false;
|
||||
};
|
||||
let Some((_, factor)) = tile.page.filter(|(_, f)| *f > 0) else { return false };
|
||||
// The texture's first texel, at the page's reduction.
|
||||
let (ox, oy) = (i64::from(tile.rect[0] / factor), i64::from(tile.rect[1] / factor));
|
||||
let (x, y) = (i64::from(buf.rect.x0) - ox, i64::from(buf.rect.y0) - oy);
|
||||
let texture = &tile.texture;
|
||||
if x < 0 || y < 0 || x + i64::from(w) > i64::from(texture.width()) || y + i64::from(h) > i64::from(texture.height()) {
|
||||
return false;
|
||||
}
|
||||
let format = texture.format();
|
||||
self.rs.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x: x as u32, y: y as u32, z: 0 }, aspect: wgpu::TextureAspect::All },
|
||||
&texels(format, &buf.px),
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn upload_page(&self, p: PageUpload<'_>) -> bool {
|
||||
let PageUpload { key, page, size, depth, factor, core, buf } = p;
|
||||
let (w, h) = (buf.rect.width(), buf.rect.height());
|
||||
if !self.health.is_ok()
|
||||
|| factor == 0
|
||||
|| w == 0
|
||||
|| h == 0
|
||||
|| w > MAX_PAGE_SIDE
|
||||
|| h > MAX_PAGE_SIDE
|
||||
|| buf.rect.x0 < 0
|
||||
|| buf.rect.y0 < 0
|
||||
|| buf.px.len() as u64 != u64::from(w) * u64::from(h)
|
||||
|| core.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (Some(x), Some(y), Some(dw), Some(dh)) =
|
||||
((buf.rect.x0 as u32).checked_mul(factor), (buf.rect.y0 as u32).checked_mul(factor), w.checked_mul(factor), h.checked_mul(factor))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let format = self.format_for(depth, size);
|
||||
let bytes = u64::from(w) * u64::from(h) * texel_bytes(format);
|
||||
let (device, queue) = (&self.rs.device, &self.rs.queue);
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return false };
|
||||
let budget = res.canvas_budget.load(Ordering::Relaxed);
|
||||
if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format || d.tiles.iter().any(|t| t.page.is_none())) {
|
||||
res.docs.insert(key, DocTextures { size, format, tiles: Vec::new() });
|
||||
}
|
||||
let Some(d) = res.docs.get_mut(&key) else { return false };
|
||||
let replaced = d.tiles.iter().position(|t| t.page.is_some_and(|(id, _)| id == page));
|
||||
let held =
|
||||
d.page_bytes() - replaced.map_or(0, |i| u64::from(d.tiles[i].texture.width()) * u64::from(d.tiles[i].texture.height()) * texel_bytes(format));
|
||||
if held.saturating_add(bytes) > budget {
|
||||
return false;
|
||||
}
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_viewport_page"),
|
||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let rect = [x, y, dw, dh];
|
||||
let uniform =
|
||||
init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM);
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_viewport_page"),
|
||||
layout: &res.tile_bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&texels(format, &buf.px),
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
let Some(d) = res.docs.get_mut(&key) else { return false };
|
||||
if let Some(i) = replaced {
|
||||
d.tiles.remove(i);
|
||||
}
|
||||
let tile = Tile { rect, texture, levels: vec![view], bind_group, _uniform: uniform, page: Some((page, factor)) };
|
||||
// Coarser pages first: where pages of two zooms overlap, the finer one is drawn on top.
|
||||
let at = d.tiles.iter().position(|t| t.page.is_some_and(|(_, f)| f < factor)).unwrap_or(d.tiles.len());
|
||||
d.tiles.insert(at, tile);
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop the pages of `key` not in `live` (all of them, and the key, when `live` is empty).
|
||||
pub fn retain_pages(&self, key: u64, live: &[u64]) {
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||
if live.is_empty() {
|
||||
res.docs.remove(&key);
|
||||
} else if let Some(d) = res.docs.get_mut(&key) {
|
||||
d.tiles.retain(|t| t.page.is_some_and(|(id, _)| live.contains(&id)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Free document `key`'s texture set and what the compositor holds for it, once it is drawn
|
||||
/// from pages instead: memory the pages are budgeted against.
|
||||
pub fn release_texture_set(&self, key: u64) {
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||
res.docs.remove(&key);
|
||||
if let Some(c) = res.compositor.as_mut() {
|
||||
c.forget_doc(photocraft_doc::DocId(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// The pages of `key` the GPU holds (page ids), and the bytes they take.
|
||||
pub fn pages(&self, key: u64) -> (Vec<u64>, u64) {
|
||||
let r = self.rs.renderer.read();
|
||||
let Some(d) = r.callback_resources.get::<Resources>().and_then(|res| res.docs.get(&key)) else { return (Vec::new(), 0) };
|
||||
(d.tiles.iter().filter_map(|t| t.page.map(|(id, _)| id)).collect(), d.page_bytes())
|
||||
}
|
||||
|
||||
/// The per-document display texture budget ([`super::canvas_budget`]) pages are held to.
|
||||
pub fn canvas_budget(&self) -> u64 {
|
||||
self.canvas_budget.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,9 @@ pub(super) struct Tile {
|
||||
pub(super) levels: Vec<wgpu::TextureView>,
|
||||
pub(super) bind_group: wgpu::BindGroup,
|
||||
pub(super) _uniform: wgpu::Buffer,
|
||||
/// A viewport page ([`GpuCanvas::upload_page`]): its id and reduction factor (document
|
||||
/// pixels per texel); `None` for a tile of a whole-document texture set.
|
||||
pub(super) page: Option<(u64, u32)>,
|
||||
}
|
||||
|
||||
fn uniform_entry(binding: u32, size: u64, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
|
||||
|
||||
@@ -9,7 +9,7 @@ struct View {
|
||||
g: vec4<f32>, // gamut warning rgb, 32-bit preview 1 / gamma
|
||||
h: vec4<f32>, // cos, sin, center_doc_x, center_doc_y
|
||||
};
|
||||
struct Tile { r: vec4<f32> }; // x, y, w, h in doc px
|
||||
struct Tile { r: vec4<f32>, core: vec4<f32> }; // x, y, w, h in doc px; drawn x0, y0, x1, y1
|
||||
|
||||
@group(0) @binding(0) var<uniform> view: View;
|
||||
@group(0) @binding(1) var samp: sampler;
|
||||
@@ -163,6 +163,8 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = in.pos.xy;
|
||||
let scale = view.a.z;
|
||||
let d = px_to_doc(p) - view.d.xy; // document pixel coordinates (within this copy)
|
||||
// A viewport page's apron is only sampled; its neighbour draws those pixels.
|
||||
if (any(d < tile.core.xy) || any(d >= tile.core.zw)) { discard; }
|
||||
let size = tile.r.zw;
|
||||
let t = d - tile.r.xy; // texel coordinates within this tile
|
||||
let mode = view.c.x;
|
||||
|
||||
@@ -677,11 +677,23 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) {
|
||||
if let Some(gpu) = &app.gpu {
|
||||
// Keep each document's texture plus its preview textures (filter preview, adjustment proxy);
|
||||
// retaining only document ids freed the previews every frame (blank canvas while previewing).
|
||||
let mut live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
|
||||
let mut live: Vec<u64> = app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62), super::viewport_pages::stream_key(st.doc.id.0)])
|
||||
.collect();
|
||||
live.extend(crate::adjust_preview::gpu_keys(app));
|
||||
gpu.retain(&live);
|
||||
}
|
||||
crate::background_redraw::retain(app);
|
||||
// Page work for closed documents (or of a dropped GPU canvas) is cancelled.
|
||||
if app.gpu.is_none() {
|
||||
app.streams.clear();
|
||||
} else {
|
||||
let ids: Vec<_> = app.session.documents().iter().map(|st| st.doc.id).collect();
|
||||
app.streams.retain(|id, _| ids.contains(id));
|
||||
}
|
||||
// Upload markers must not outlive the resources they describe: native reopen can reuse
|
||||
// both the document ID and revision before the next frame while a preview dialog stays open.
|
||||
let documents = app.session.documents();
|
||||
|
||||
@@ -0,0 +1,942 @@
|
||||
//! Viewport pages for a document whose display textures don't fit the GPU canvas budget
|
||||
//! ([`super::gpu_canvas::GpuCanvas::fits_canvas`], from the detected RAM and VRAM, so also with
|
||||
//! GPU Memory on Auto). Instead of the whole-document texture set, the view's pages are rendered
|
||||
//! on two background workers at the view's reduction and uploaded as their own GPU textures,
|
||||
//! under [`stream_key`]: the work and the GPU memory follow what is on screen, not the document.
|
||||
//!
|
||||
//! Documents that fit keep the texture set and everything around it (the layer budget, deferred
|
||||
//! refresh, background redraw). Without a GPU canvas the CPU canvas draws every document.
|
||||
//!
|
||||
//! Pages are derived data: an edit marks the pages its damage reaches dirty (the rest stay) and,
|
||||
//! for a small one, re-renders just the texels it reaches into the page held; a
|
||||
//! dirty page stays on screen until its replacement is uploaded, and work for pages that are no
|
||||
//! longer wanted (a pan, a zoom, an edit) is cancelled.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_engine::display_color::CanvasDisplay;
|
||||
use photocraft_geom::Rect as DRect;
|
||||
|
||||
use super::textures::texture_buffer;
|
||||
use super::{ViewXform, canvas_display, damage_since, display_doc, effect_reach, texture_key, visible_doc_rect};
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Document pixels per page side at the page's reduction.
|
||||
pub(crate) const PAGE_SIDE: u32 = 512;
|
||||
/// The most pages a view asks for: coarser pages are used beyond it.
|
||||
const MAX_PAGES: usize = 256;
|
||||
/// Pages rendering at once, over all views and documents.
|
||||
const MAX_JOBS: usize = 2;
|
||||
/// Pages uploaded per frame, and the texel bytes they may take: staging stays bounded however
|
||||
/// many pages finish at once.
|
||||
const UPLOADS_PER_FRAME: usize = 2;
|
||||
const UPLOAD_BYTES_PER_FRAME: u64 = 32 << 20;
|
||||
/// The coarsest reduction (document pixels per texel).
|
||||
const MAX_FACTOR: u32 = 1 << 16;
|
||||
|
||||
static ACTIVE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// The GPU canvas key of `doc`'s pages, apart from its texture set and the preview keys (bits 58
|
||||
/// to 63 tag those: the filter preview, adjustment proxies, gradients and thumbnails).
|
||||
pub(crate) fn stream_key(doc: u64) -> u64 {
|
||||
doc ^ (1u64 << 57)
|
||||
}
|
||||
|
||||
/// A page: `factor` document pixels per texel, column `x` and row `y` of `PAGE_SIDE` texels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct PageId {
|
||||
pub factor: u32,
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
}
|
||||
|
||||
impl PageId {
|
||||
/// The GPU canvas page id.
|
||||
pub fn id(self) -> u64 {
|
||||
(u64::from(self.factor.trailing_zeros()) << 56) | (u64::from(self.y) << 28) | u64::from(self.x)
|
||||
}
|
||||
|
||||
/// The document pixels this page draws.
|
||||
pub fn core(self, size: [u32; 2]) -> DRect {
|
||||
let side = u64::from(PAGE_SIDE) * u64::from(self.factor);
|
||||
let (x, y) = (u64::from(self.x) * side, u64::from(self.y) * side);
|
||||
if self.factor == 0 || x >= u64::from(size[0]) || y >= u64::from(size[1]) {
|
||||
return DRect::EMPTY;
|
||||
}
|
||||
let end = |o: u64, n: u32| (o + side).min(u64::from(n)) as i32;
|
||||
DRect::new(x as i32, y as i32, end(x, size[0]), end(y, size[1]))
|
||||
}
|
||||
|
||||
/// The document pixels it is rendered from: the core and one texel around it, so filtering
|
||||
/// at its edge matches its neighbour's.
|
||||
pub fn source(self, size: [u32; 2]) -> DRect {
|
||||
let core = self.core(size);
|
||||
if core.is_empty() {
|
||||
return core;
|
||||
}
|
||||
core.inflate(self.factor as i32).intersect(&DRect::new(0, 0, size[0] as i32, size[1] as i32))
|
||||
}
|
||||
}
|
||||
|
||||
/// The page reduction for `zoom` (screen pixels per document pixel): the largest power of two at
|
||||
/// which a texel still covers at most one screen pixel.
|
||||
pub(crate) fn factor(zoom: f32) -> u32 {
|
||||
if !zoom.is_finite() || zoom <= 0.0 {
|
||||
return 1;
|
||||
}
|
||||
let mut k = 1u32;
|
||||
while k < MAX_FACTOR && zoom * (k * 2) as f32 <= 1.0 {
|
||||
k *= 2;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The part of `page` a change to `damage` reaches, widened to whole texels: rendered from just
|
||||
/// that area, its texels are the ones the whole page has (reductions are globally aligned).
|
||||
fn partial_region(page: PageId, size: [u32; 2], damage: DRect) -> DRect {
|
||||
let k = page.factor.max(1) as i32;
|
||||
let source = page.source(size);
|
||||
let d = damage.intersect(&source);
|
||||
if d.is_empty() {
|
||||
return DRect::EMPTY;
|
||||
}
|
||||
let up = |v: i32| v.div_euclid(k).saturating_add(i32::from(v.rem_euclid(k) != 0)).saturating_mul(k);
|
||||
DRect::new(d.x0.div_euclid(k) * k, d.y0.div_euclid(k) * k, up(d.x1), up(d.y1)).intersect(&source)
|
||||
}
|
||||
|
||||
/// The pages covering `visible` of a `size` document at `zoom`, centre first: at most
|
||||
/// `max_pages` (coarser when the view needs more).
|
||||
pub(crate) fn pages(size: [u32; 2], visible: DRect, zoom: f32, max_pages: usize) -> Vec<PageId> {
|
||||
let visible = visible.intersect(&DRect::new(0, 0, size[0].min(i32::MAX as u32) as i32, size[1].min(i32::MAX as u32) as i32));
|
||||
if visible.is_empty() || max_pages == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut k = factor(zoom);
|
||||
loop {
|
||||
let side = u64::from(PAGE_SIDE) * u64::from(k);
|
||||
let span = |a: i32, b: i32| (a as u64 / side, (b as u64).div_ceil(side));
|
||||
let ((x0, x1), (y0, y1)) = (span(visible.x0, visible.x1), span(visible.y0, visible.y1));
|
||||
if (x1 - x0) * (y1 - y0) <= max_pages.min(MAX_PAGES) as u64 {
|
||||
let mut out: Vec<PageId> = (y0..y1).flat_map(|y| (x0..x1).map(move |x| PageId { factor: k, x: x as u32, y: y as u32 })).collect();
|
||||
let (cx, cy) = ((x0 + x1) as i64, (y0 + y1) as i64);
|
||||
out.sort_by_key(|p| (i64::from(p.x) * 2 + 1 - cx).abs() + (i64::from(p.y) * 2 + 1 - cy).abs());
|
||||
return out;
|
||||
}
|
||||
if k >= MAX_FACTOR {
|
||||
return Vec::new();
|
||||
}
|
||||
k *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a stream's pages show: the document revision, its preview and display keys, its size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Identity {
|
||||
pub revision: u64,
|
||||
/// The raw preview key ([`display_doc`]); `display` is folded in for [`damage_since`].
|
||||
pub preview: u64,
|
||||
pub display: u64,
|
||||
pub size: [u32; 2],
|
||||
}
|
||||
|
||||
/// A page on the GPU.
|
||||
#[derive(Debug)]
|
||||
struct Page {
|
||||
/// Rendered before the latest edit that reached it: shown until its replacement arrives.
|
||||
dirty: bool,
|
||||
/// The exact reduction (not the quick preview).
|
||||
exact: bool,
|
||||
/// While dirty, the only document area that changed since it was uploaded (aligned to its
|
||||
/// texels): just that part is rendered again and written into the page. `None`: all of it.
|
||||
partial: Option<DRect>,
|
||||
/// Bumped whenever the page's content is invalidated: a result from older work is dropped.
|
||||
epoch: u64,
|
||||
touched: u64,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
page: PageId,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// A finished page render.
|
||||
pub(crate) struct Done {
|
||||
pub page: PageId,
|
||||
pub epoch: u64,
|
||||
pub exact: bool,
|
||||
/// Rendered for [`Page::partial`]: written into the page held, not a page of its own.
|
||||
pub region: Option<DRect>,
|
||||
pub result: Result<photocraft_compose::Buffer, String>,
|
||||
}
|
||||
|
||||
/// The counters `inspect` reports.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Stats {
|
||||
pub wanted: usize,
|
||||
pub ready: usize,
|
||||
pub resident: usize,
|
||||
pub resident_bytes: u64,
|
||||
pub limit: u64,
|
||||
pub jobs: usize,
|
||||
pub rendered: u64,
|
||||
pub uploads: u64,
|
||||
pub cancelled: u64,
|
||||
pub dirtied: u64,
|
||||
/// Pages updated in part, by re-rendering only what an edit reached.
|
||||
pub partial: u64,
|
||||
pub evicted: u64,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// One document's viewport pages ([`PhotocraftApp::streams`]).
|
||||
pub(crate) struct Stream {
|
||||
pages: HashMap<PageId, Page>,
|
||||
/// Epochs of pages not (or no longer) on the GPU, so their results can still be checked.
|
||||
epochs: HashMap<PageId, u64>,
|
||||
jobs: Vec<Job>,
|
||||
ready: Vec<Done>,
|
||||
failed: HashSet<(PageId, u64)>,
|
||||
tx: mpsc::Sender<Done>,
|
||||
rx: mpsc::Receiver<Done>,
|
||||
pub seen: Option<Identity>,
|
||||
clock: u64,
|
||||
/// The page ids last given to the GPU canvas, to tell it only of changes.
|
||||
synced: Vec<u64>,
|
||||
health: (u64, u64),
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
impl Default for Stream {
|
||||
fn default() -> Self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
Self {
|
||||
pages: HashMap::new(),
|
||||
epochs: HashMap::new(),
|
||||
jobs: Vec::new(),
|
||||
ready: Vec::new(),
|
||||
failed: HashSet::new(),
|
||||
tx,
|
||||
rx,
|
||||
seen: None,
|
||||
clock: 0,
|
||||
synced: Vec::new(),
|
||||
health: (0, 0),
|
||||
stats: Stats::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Stream {
|
||||
fn drop(&mut self) {
|
||||
for j in &self.jobs {
|
||||
j.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn epoch(&self, page: PageId) -> u64 {
|
||||
self.pages.get(&page).map_or_else(|| self.epochs.get(&page).copied().unwrap_or(0), |p| p.epoch)
|
||||
}
|
||||
|
||||
fn bump(&mut self, page: PageId) {
|
||||
let e = self.epoch(page) + 1;
|
||||
match self.pages.get_mut(&page) {
|
||||
Some(p) => p.epoch = e,
|
||||
None => {
|
||||
self.epochs.insert(page, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The document changed to `now`: the pages whose source `damage` reaches (every page
|
||||
/// without it) are dirty, and work on them is cancelled. Nothing else is scanned: the cost
|
||||
/// is the pages held, not the document's layers or tiles.
|
||||
pub fn invalidate(&mut self, now: Identity, damage: Option<DRect>) {
|
||||
let size_changed = self.seen.is_some_and(|s| s.size != now.size);
|
||||
self.seen = Some(now);
|
||||
if size_changed {
|
||||
self.reset();
|
||||
return;
|
||||
}
|
||||
let hit = |p: PageId| damage.is_none_or(|d| !p.source(now.size).intersect(&d).is_empty());
|
||||
let pages: Vec<PageId> = self.pages.keys().copied().filter(|p| hit(*p)).collect();
|
||||
for p in &pages {
|
||||
if let Some(page) = self.pages.get_mut(p) {
|
||||
// An exact page that is current, or only partly stale, takes the new damage too.
|
||||
let partial = damage.filter(|_| page.exact && (!page.dirty || page.partial.is_some())).and_then(|d| {
|
||||
let r = page.partial.unwrap_or(DRect::EMPTY).union(&partial_region(*p, now.size, d));
|
||||
let source = p.source(now.size);
|
||||
// Most of the page: rendering it whole is no slower.
|
||||
(r.width() as u64 * r.height() as u64 * 2 <= source.width() as u64 * source.height() as u64).then_some(r)
|
||||
});
|
||||
page.dirty = true;
|
||||
page.exact &= partial.is_some();
|
||||
page.partial = partial;
|
||||
}
|
||||
self.bump(*p);
|
||||
}
|
||||
self.stats.dirtied += pages.len() as u64;
|
||||
let jobs: Vec<PageId> = self.jobs.iter().map(|j| j.page).filter(|p| hit(*p)).collect();
|
||||
for p in jobs {
|
||||
if !self.pages.contains_key(&p) {
|
||||
self.bump(p);
|
||||
}
|
||||
}
|
||||
self.cancel_stale();
|
||||
self.ready.retain(|d| !hit(d.page));
|
||||
self.failed.retain(|(p, _)| !hit(*p));
|
||||
}
|
||||
|
||||
/// Forget every page (the GPU canvas lost them, or the document changed size).
|
||||
pub fn reset(&mut self) {
|
||||
let pages: Vec<PageId> = self.pages.keys().chain(self.jobs.iter().map(|j| &j.page)).copied().collect();
|
||||
for p in pages {
|
||||
let e = self.epoch(p) + 1;
|
||||
self.epochs.insert(p, e);
|
||||
}
|
||||
self.pages.clear();
|
||||
self.ready.clear();
|
||||
self.failed.clear();
|
||||
self.synced.clear();
|
||||
self.cancel_stale();
|
||||
}
|
||||
|
||||
/// Cancel jobs whose page moved to a newer epoch.
|
||||
fn cancel_stale(&mut self) {
|
||||
for j in &self.jobs {
|
||||
if j.epoch != self.epoch(j.page) && !j.cancel.swap(true, Ordering::Relaxed) {
|
||||
self.stats.cancelled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take finished work, cancel work for pages not `wanted`, and start work for wanted pages
|
||||
/// that are missing, dirty or still a quick preview (`faithful`: the document has a quick
|
||||
/// preview at reductions), as `spawn(page, epoch, exact, cancel, region)` allows: `region`, the
|
||||
/// part of a held page to render again ([`Page::partial`]), or the whole page.
|
||||
pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc<AtomicBool>, Option<DRect>) -> bool) {
|
||||
self.clock += 1;
|
||||
let want: HashSet<PageId> = wanted.iter().copied().collect();
|
||||
while let Ok(d) = self.rx.try_recv() {
|
||||
let Some(i) = self.jobs.iter().position(|j| j.page == d.page && j.epoch == d.epoch) else { continue };
|
||||
// Cancelled work ends in an interruption, not a failure: the page is asked for again.
|
||||
let cancelled = self.jobs.swap_remove(i).cancel.load(Ordering::Relaxed);
|
||||
if cancelled || d.epoch != self.epoch(d.page) || !want.contains(&d.page) {
|
||||
continue;
|
||||
}
|
||||
// A part of a page no longer held (evicted meanwhile): the whole page is asked for.
|
||||
if d.region.is_some() && !self.pages.get(&d.page).is_some_and(|q| q.dirty && q.partial == d.region) {
|
||||
continue;
|
||||
}
|
||||
match &d.result {
|
||||
Ok(_) => {
|
||||
self.stats.rendered += 1;
|
||||
self.ready.retain(|r| r.page != d.page);
|
||||
self.ready.push(d);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("viewport page {:?}: {e}", d.page);
|
||||
self.stats.last_error = Some(e.clone());
|
||||
self.failed.insert((d.page, d.epoch));
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in &self.jobs {
|
||||
if !want.contains(&j.page) && !j.cancel.swap(true, Ordering::Relaxed) {
|
||||
self.stats.cancelled += 1;
|
||||
}
|
||||
}
|
||||
self.ready.retain(|d| want.contains(&d.page));
|
||||
for p in wanted {
|
||||
if let Some(page) = self.pages.get_mut(p) {
|
||||
page.touched = self.clock;
|
||||
}
|
||||
}
|
||||
let current = |s: &Self, p: &PageId| s.pages.get(p).is_some_and(|q| !q.dirty) || s.ready.iter().any(|d| d.page == *p);
|
||||
let all_shown = wanted.iter().all(|p| current(self, p));
|
||||
for p in wanted {
|
||||
let live = self.jobs.iter().filter(|j| !j.cancel.load(Ordering::Relaxed)).count();
|
||||
if live >= MAX_JOBS {
|
||||
break;
|
||||
}
|
||||
let epoch = self.epoch(*p);
|
||||
if self.jobs.iter().any(|j| j.page == *p && j.epoch == epoch) || self.failed.contains(&(*p, epoch)) {
|
||||
continue;
|
||||
}
|
||||
// First every page in view (a quick preview at a reduction), then the exact ones.
|
||||
let exact = p.factor == 1 || !faithful || all_shown;
|
||||
let have = self.ready.iter().find(|d| d.page == *p).map(|d| d.exact).or_else(|| self.pages.get(p).filter(|q| !q.dirty).map(|q| q.exact));
|
||||
if have.is_some_and(|e| e || !exact) {
|
||||
continue;
|
||||
}
|
||||
let region = self.pages.get(p).filter(|q| q.dirty).and_then(|q| q.partial);
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
if !spawn(*p, epoch, exact || region.is_some(), cancel.clone(), region) {
|
||||
break;
|
||||
}
|
||||
self.jobs.push(Job { page: *p, epoch, cancel });
|
||||
}
|
||||
self.stats.wanted = wanted.len();
|
||||
self.stats.ready = wanted.iter().filter(|p| self.pages.contains_key(p)).count();
|
||||
self.stats.jobs = self.jobs.len();
|
||||
}
|
||||
|
||||
/// Retry failed pages once the scratch storage or memory configuration changed (`health`).
|
||||
pub fn retry_after(&mut self, health: (u64, u64)) {
|
||||
if health != self.health {
|
||||
self.health = health;
|
||||
self.failed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a wanted page can't be shown: its render or upload failed and nothing older of it
|
||||
/// is held, so the view would have a hole (the CPU canvas draws instead).
|
||||
pub fn stuck(&self, wanted: &[PageId]) -> bool {
|
||||
wanted.iter().any(|p| !self.pages.contains_key(p) && self.failed.contains(&(*p, self.epoch(*p))))
|
||||
}
|
||||
|
||||
/// Whether work is outstanding (the view repaints until it is done).
|
||||
pub fn busy(&self) -> bool {
|
||||
!self.jobs.is_empty() || !self.ready.is_empty()
|
||||
}
|
||||
|
||||
/// The finished pages to upload this frame: at most [`UPLOADS_PER_FRAME`], and
|
||||
/// [`UPLOAD_BYTES_PER_FRAME`] of texels (`texel_bytes` each), the first always.
|
||||
pub fn take_uploads(&mut self, wanted: &[PageId], texel_bytes: u64) -> Vec<Done> {
|
||||
let order = |d: &Done| wanted.iter().position(|p| *p == d.page).unwrap_or(usize::MAX);
|
||||
self.ready.sort_by_key(order);
|
||||
let mut out = Vec::new();
|
||||
let mut bytes = 0u64;
|
||||
while out.len() < UPLOADS_PER_FRAME && !self.ready.is_empty() {
|
||||
let b = self.ready[0].result.as_ref().map_or(0, |b| b.px.len() as u64 * texel_bytes);
|
||||
if !out.is_empty() && bytes + b > UPLOAD_BYTES_PER_FRAME {
|
||||
break;
|
||||
}
|
||||
bytes += b;
|
||||
out.push(self.ready.remove(0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Make room for `bytes` more under `limit` by evicting the least recently wanted pages not
|
||||
/// in `wanted`: whether any was evicted, or `None` when the wanted pages leave no room.
|
||||
pub fn make_room(&mut self, page: PageId, bytes: u64, limit: u64, wanted: &[PageId]) -> Option<bool> {
|
||||
let held = |s: &Self| s.pages.iter().filter(|(p, _)| **p != page).map(|(_, q)| q.bytes).sum::<u64>();
|
||||
let mut evicted = false;
|
||||
while held(self) + bytes > limit {
|
||||
let cold = self.pages.iter().filter(|(p, _)| **p != page && !wanted.contains(p)).min_by_key(|(_, q)| q.touched).map(|(p, _)| *p)?;
|
||||
self.drop_page(cold);
|
||||
self.stats.evicted += 1;
|
||||
evicted = true;
|
||||
}
|
||||
Some(evicted)
|
||||
}
|
||||
|
||||
/// The GPU page ids of the pages held.
|
||||
pub fn ids(&self) -> Vec<u64> {
|
||||
self.pages.keys().map(|p| p.id()).collect()
|
||||
}
|
||||
|
||||
fn drop_page(&mut self, p: PageId) {
|
||||
if let Some(q) = self.pages.remove(&p) {
|
||||
self.epochs.insert(p, q.epoch);
|
||||
}
|
||||
}
|
||||
|
||||
/// `done` is on the GPU (`ok`), or couldn't be stored.
|
||||
pub fn uploaded(&mut self, done: &Done, bytes: u64, ok: bool) {
|
||||
if !ok {
|
||||
self.stats.last_error = Some("the GPU canvas couldn't store a viewport page".into());
|
||||
self.failed.insert((done.page, done.epoch));
|
||||
return;
|
||||
}
|
||||
self.stats.uploads += 1;
|
||||
self.epochs.remove(&done.page);
|
||||
if done.region.is_some()
|
||||
&& let Some(page) = self.pages.get_mut(&done.page)
|
||||
{
|
||||
(page.dirty, page.partial, page.epoch, page.touched) = (false, None, done.epoch, self.clock);
|
||||
self.stats.partial += 1;
|
||||
return;
|
||||
}
|
||||
self.pages.insert(done.page, Page { dirty: false, exact: done.exact, partial: None, epoch: done.epoch, touched: self.clock, bytes });
|
||||
}
|
||||
|
||||
/// The part of `page` written by `done` couldn't be stored: drop the page, so it is
|
||||
/// rendered whole.
|
||||
pub fn partial_failed(&mut self, page: PageId) {
|
||||
self.drop_page(page);
|
||||
}
|
||||
|
||||
/// Drop pages no longer worth keeping: once every wanted page is shown and current, the
|
||||
/// dirty ones and the ones finer than the view (they would draw over it). Coarser clean
|
||||
/// pages stay (under the view's own, for a quick pan or zoom out) until evicted. The GPU
|
||||
/// page ids to keep when they changed since the last call.
|
||||
pub fn prune(&mut self, wanted: &[PageId]) -> Option<Vec<u64>> {
|
||||
let all_current = wanted.iter().all(|p| self.pages.get(p).is_some_and(|q| !q.dirty));
|
||||
if all_current && let Some(f) = wanted.first().map(|p| p.factor) {
|
||||
let drop: Vec<PageId> = self.pages.iter().filter(|(p, q)| !wanted.contains(p) && (q.dirty || p.factor < f)).map(|(p, _)| *p).collect();
|
||||
for p in drop {
|
||||
self.drop_page(p);
|
||||
}
|
||||
}
|
||||
// Epochs only guard results still on their way.
|
||||
let jobs: HashSet<PageId> = self.jobs.iter().map(|j| j.page).collect();
|
||||
self.epochs.retain(|p, _| jobs.contains(p));
|
||||
let mut ids = self.ids();
|
||||
ids.sort_unstable();
|
||||
self.stats.resident = ids.len();
|
||||
self.stats.resident_bytes = self.pages.values().map(|q| q.bytes).sum();
|
||||
(ids != self.synced).then(|| {
|
||||
self.synced = ids.clone();
|
||||
ids
|
||||
})
|
||||
}
|
||||
|
||||
/// The pages this stream believes are on the GPU.
|
||||
pub fn resident(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `page` of `doc` on a worker, if one of the [`MAX_JOBS`] is free.
|
||||
fn spawn(
|
||||
ctx: &egui::Context,
|
||||
tx: mpsc::Sender<Done>,
|
||||
doc: Arc<Document>,
|
||||
display: Option<Arc<CanvasDisplay>>,
|
||||
(page, epoch, exact, region): (PageId, u64, bool, Option<DRect>),
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> bool {
|
||||
if ACTIVE.fetch_add(1, Ordering::AcqRel) >= MAX_JOBS {
|
||||
ACTIVE.fetch_sub(1, Ordering::AcqRel);
|
||||
return false;
|
||||
}
|
||||
struct Slot(egui::Context);
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE.fetch_sub(1, Ordering::AcqRel);
|
||||
// A view waiting for a free worker (another document's) can start now.
|
||||
self.0.request_repaint();
|
||||
}
|
||||
}
|
||||
let slot = Slot(ctx.clone());
|
||||
let work = move || {
|
||||
let _slot = slot;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&doc, page, exact, region, display.as_deref(), &cancel)))
|
||||
.unwrap_or_else(|_| Err("viewport page render panicked".into()));
|
||||
let _ = tx.send(Done { page, epoch, exact, region, result });
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::thread::Builder::new().name("pc-viewport-page".into()).spawn(work).is_ok()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
work();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// One page as the GPU canvas stores it (encoded for the display like the texture set).
|
||||
fn render(
|
||||
doc: &Document,
|
||||
page: PageId,
|
||||
exact: bool,
|
||||
region: Option<DRect>,
|
||||
display: Option<&CanvasDisplay>,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<photocraft_compose::Buffer, String> {
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
let source = region.unwrap_or_else(|| page.source(size));
|
||||
let rows = u64::from(source.height().min(128));
|
||||
let _working = photocraft_raster::memory::try_reserve(u64::from(source.width()) * rows * 16 + (16 << 20))?;
|
||||
let before = photocraft_raster::spill::read_error_generation();
|
||||
let check = || cancel.load(Ordering::Relaxed);
|
||||
let i = photocraft_raster::Interrupt::cancel_only(&check);
|
||||
let buf = if exact {
|
||||
photocraft_compose::viewport::render_page(doc, source, page.factor, i)?
|
||||
} else {
|
||||
photocraft_compose::viewport::preview_page(doc, source, page.factor, i)?
|
||||
};
|
||||
i.check().map_err(|e| e.to_string())?;
|
||||
photocraft_raster::spill::check_since(before)?;
|
||||
Ok(texture_buffer(display, &buf).into_owned())
|
||||
}
|
||||
|
||||
/// Draw document `idx` from viewport pages when its texture set doesn't fit the GPU canvas
|
||||
/// budget: true when the view should paint [`stream_key`] with the GPU canvas.
|
||||
pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: &ViewXform, zoom: f32) -> bool {
|
||||
let Some(gpu) = app.gpu.clone() else { return false };
|
||||
if app.ui.view.pattern_preview || gpu.fault().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some((revision, last_damage, id, size)) = app
|
||||
.session
|
||||
.documents()
|
||||
.get(idx)
|
||||
.map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id, st.doc.size))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let size = [size.width, size.height];
|
||||
let (doc, raw_key) = display_doc(app, idx);
|
||||
// A size-changing preview draws as main does; a document that fits keeps its texture set.
|
||||
if [doc.size.width, doc.size.height] != size || gpu.fits_canvas(&doc) {
|
||||
if let Some(s) = app.streams.remove(&id) {
|
||||
drop(s);
|
||||
gpu.retain_pages(stream_key(id.0), &[]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let (display, _) = canvas_display(app, &doc, None);
|
||||
let display_key = texture_key(display.as_deref());
|
||||
let now = Identity { revision, preview: raw_key, display: display_key, size };
|
||||
let seen = app.streams.get(&id).and_then(|s| s.seen);
|
||||
if seen != Some(now) {
|
||||
let damage = seen
|
||||
.filter(|s| s.size == size && s.display == display_key)
|
||||
.and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage));
|
||||
app.streams.entry(id).or_default().invalidate(now, damage);
|
||||
}
|
||||
let key = stream_key(id.0);
|
||||
// Half the per-document canvas budget: the rest is headroom for what the compositor needs.
|
||||
let limit = gpu.canvas_budget() / 2;
|
||||
let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1);
|
||||
let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX);
|
||||
let wanted = pages(size, visible_doc_rect(xf), zoom, max_pages);
|
||||
if gpu.texture_info(id.0).is_some() {
|
||||
// Switching from the texture set: free it, the pages are budgeted against that memory.
|
||||
gpu.release_texture_set(id.0);
|
||||
}
|
||||
let stream = app.streams.entry(id).or_default();
|
||||
stream.retry_after((photocraft_raster::spill::read_error_generation(), photocraft_raster::memory::configuration_generation()));
|
||||
// No room for a single page, or a page that failed: the CPU canvas draws the document, as
|
||||
// main does for every document over the budget.
|
||||
if max_pages == 0 || stream.stuck(&wanted) {
|
||||
return false;
|
||||
}
|
||||
let faithful = photocraft_compose::proxy::proxy_faithful(&doc);
|
||||
let texel_bytes = gpu.page_texture_bytes(doc.depth, size, 1, 1);
|
||||
// The GPU canvas dropped the pages (released, or pruned with a closed document): start over.
|
||||
if stream.resident() > 0 && gpu.pages(key).0.len() != stream.resident() {
|
||||
stream.reset();
|
||||
}
|
||||
// Drawn from pages: no texture set, so no layer budget or background redraw applies.
|
||||
crate::background_redraw::cancel(app, id);
|
||||
if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) {
|
||||
app.perf.gpu_layer_fit = None;
|
||||
}
|
||||
app.navigator_gpu.remove(&id);
|
||||
crate::render_path::note(app, id.0, crate::render_path::Path::Gpu { full: true });
|
||||
let stream = app.streams.entry(id).or_default();
|
||||
let tx = stream.tx.clone();
|
||||
stream.update(&wanted, faithful, |page, epoch, exact, cancel, region| {
|
||||
spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact, region), cancel)
|
||||
});
|
||||
for d in stream.take_uploads(&wanted, texel_bytes) {
|
||||
let Ok(buf) = &d.result else { continue };
|
||||
if d.region.is_some() {
|
||||
if gpu.update_page(key, d.page.id(), buf) {
|
||||
stream.uploaded(&d, 0, true);
|
||||
} else {
|
||||
stream.partial_failed(d.page);
|
||||
gpu.retain_pages(key, &stream.ids());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let bytes = buf.px.len() as u64 * texel_bytes;
|
||||
let ok = match stream.make_room(d.page, bytes, limit, &wanted) {
|
||||
None => false,
|
||||
Some(evicted) => {
|
||||
if evicted {
|
||||
gpu.retain_pages(key, &stream.ids());
|
||||
}
|
||||
gpu.upload_page(crate::gpu_canvas::PageUpload {
|
||||
key,
|
||||
page: d.page.id(),
|
||||
size,
|
||||
depth: doc.depth,
|
||||
factor: d.page.factor,
|
||||
core: d.page.core(size),
|
||||
buf,
|
||||
})
|
||||
}
|
||||
};
|
||||
stream.uploaded(&d, bytes, ok);
|
||||
}
|
||||
if let Some(ids) = stream.prune(&wanted) {
|
||||
gpu.retain_pages(key, &ids);
|
||||
}
|
||||
stream.stats.limit = limit;
|
||||
if stream.busy() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SIZE: [u32; 2] = [20_000, 12_000];
|
||||
|
||||
fn id(revision: u64) -> Identity {
|
||||
Identity { revision, preview: 1, display: 2, size: SIZE }
|
||||
}
|
||||
|
||||
fn page(factor: u32, x: u32, y: u32) -> PageId {
|
||||
PageId { factor, x, y }
|
||||
}
|
||||
|
||||
fn done(p: PageId, epoch: u64, exact: bool, side: i32) -> Done {
|
||||
let rect = DRect::new(0, 0, side, side);
|
||||
Done { page: p, epoch, exact, region: None, result: Ok(photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; (side * side) as usize] }) }
|
||||
}
|
||||
|
||||
/// A stream holding `held` clean, exact pages.
|
||||
fn holding(held: &[PageId]) -> Stream {
|
||||
let mut s = Stream::default();
|
||||
s.invalidate(id(1), None);
|
||||
for p in held {
|
||||
s.uploaded(&done(*p, s.epoch(*p), true, 4), 100, true);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Spawns every job it is offered, recording it with its cancel flag.
|
||||
fn recorder(log: &mut Vec<(PageId, u64, bool, Arc<AtomicBool>)>) -> impl FnMut(PageId, u64, bool, Arc<AtomicBool>, Option<DRect>) -> bool + '_ {
|
||||
|p, e, x, c, _| {
|
||||
log.push((p, e, x, c));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factor_keeps_a_texel_at_most_one_screen_pixel() {
|
||||
assert_eq!(factor(4.0), 1);
|
||||
assert_eq!(factor(1.0), 1);
|
||||
assert_eq!(factor(0.5), 2);
|
||||
assert_eq!(factor(0.3), 2);
|
||||
assert_eq!(factor(0.25), 4);
|
||||
assert_eq!(factor(0.0), 1);
|
||||
assert_eq!(factor(f32::NAN), 1);
|
||||
assert_eq!(factor(1e-9), MAX_FACTOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_cover_the_view_centre_first_and_coarsen_past_the_limit() {
|
||||
let visible = DRect::new(1000, 1000, 2500, 2000);
|
||||
let got = pages(SIZE, visible, 1.0, 64);
|
||||
// Columns 1..=4, rows 1..=3 of 512 px pages.
|
||||
assert_eq!(got.len(), 4 * 3);
|
||||
assert!(got.iter().all(|p| p.factor == 1));
|
||||
let union = got.iter().fold(DRect::EMPTY, |u, p| u.union(&p.core(SIZE)));
|
||||
assert_eq!(union.intersect(&visible), visible, "every visible pixel is on a page");
|
||||
let centre = got[0].core(SIZE);
|
||||
assert!(centre.inflate(512).contains(1750, 1500), "centre first: {:?}", got[0]);
|
||||
// The whole document at 1:1 is 40×24 pages: over the limit it uses a reduction.
|
||||
let whole = DRect::new(0, 0, SIZE[0] as i32, SIZE[1] as i32);
|
||||
let coarse = pages(SIZE, whole, 1.0, 64);
|
||||
assert!(!coarse.is_empty() && coarse.len() <= 64);
|
||||
assert!(coarse.iter().all(|p| p.factor == 4), "{:?}", coarse[0]);
|
||||
assert!(pages(SIZE, whole, 1.0, 0).is_empty());
|
||||
assert!(pages(SIZE, DRect::new(-500, -500, -1, -1), 1.0, 64).is_empty(), "off the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_core_tiles_the_document_and_its_source_adds_one_texel() {
|
||||
let p = page(2, 1, 0);
|
||||
assert_eq!(p.core(SIZE), DRect::new(1024, 0, 2048, 1024));
|
||||
assert_eq!(p.source(SIZE), DRect::new(1022, 0, 2050, 1026));
|
||||
// The last column is clipped to the document.
|
||||
let last = page(1, SIZE[0] / 512, 0);
|
||||
assert_eq!(last.core(SIZE).x1, SIZE[0] as i32);
|
||||
assert!(page(1, 1000, 0).core(SIZE).is_empty());
|
||||
// Ids differ across factors and positions.
|
||||
assert_ne!(page(1, 1, 0).id(), page(2, 1, 0).id());
|
||||
assert_ne!(page(1, 1, 0).id(), page(1, 0, 1).id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_dirties_only_the_pages_it_reaches() {
|
||||
let (a, b, c) = (page(1, 0, 0), page(1, 1, 0), page(1, 4, 4));
|
||||
let mut s = holding(&[a, b, c]);
|
||||
// Damage on page a's right edge also reaches b's one-texel apron.
|
||||
s.invalidate(id(2), Some(DRect::new(500, 10, 512, 20)));
|
||||
assert!(s.pages[&a].dirty && s.pages[&b].dirty);
|
||||
assert!(!s.pages[&c].dirty && s.pages[&c].exact, "far page untouched");
|
||||
assert_eq!(s.stats.dirtied, 2);
|
||||
// A dirty page stays on screen until replaced; no damage known dirties every page.
|
||||
s.invalidate(id(3), None);
|
||||
assert_eq!(s.pages.len(), 3);
|
||||
assert!(s.pages.values().all(|p| p.dirty));
|
||||
// A new size starts over.
|
||||
s.invalidate(Identity { size: [100, 100], ..id(4) }, None);
|
||||
assert_eq!(s.resident(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_zoom_and_edits_cancel_obsolete_work() {
|
||||
let mut s = holding(&[]);
|
||||
let mut log = Vec::new();
|
||||
let view = [page(1, 0, 0), page(1, 1, 0), page(1, 2, 0)];
|
||||
s.update(&view, true, recorder(&mut log));
|
||||
assert_eq!(log.len(), MAX_JOBS, "bounded workers");
|
||||
assert!(log.iter().all(|j| j.2), "1:1 pages are rendered exactly");
|
||||
// Pan: the first page is no longer wanted.
|
||||
s.update(&view[1..], true, recorder(&mut Vec::new()));
|
||||
assert!(log[0].3.load(Ordering::Relaxed), "work for a page out of view is cancelled");
|
||||
assert!(!log[1].3.load(Ordering::Relaxed));
|
||||
// Edit on the second page: its result would be stale.
|
||||
s.invalidate(id(2), Some(DRect::new(600, 0, 601, 1)));
|
||||
assert!(log[1].3.load(Ordering::Relaxed), "work on an edited page is cancelled");
|
||||
// Its old result is dropped when it arrives, and so is a cancelled job's interruption.
|
||||
s.tx.send(Done { page: log[0].0, epoch: log[0].1, exact: true, region: None, result: Err("interrupted".into()) }).unwrap();
|
||||
s.tx.send(done(log[1].0, log[1].1, true, 4)).unwrap();
|
||||
s.update(&view[1..], true, |_, _, _, _, _| false);
|
||||
assert!(s.ready.is_empty(), "stale result dropped");
|
||||
assert!(!s.stuck(&view) && s.stats.last_error.is_none(), "a cancellation is not a failure");
|
||||
// Zoom: every page changes factor.
|
||||
let mut log2 = Vec::new();
|
||||
s.update(&[page(1, 1, 0)], true, recorder(&mut log2));
|
||||
let jobs = s.jobs.len();
|
||||
s.update(&[page(2, 0, 0)], true, |_, _, _, _, _| true);
|
||||
assert!(log2.iter().all(|j| j.3.load(Ordering::Relaxed)), "zoom cancels the old factor's work");
|
||||
assert!(s.stats.cancelled >= 3 && jobs > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reductions_show_a_quick_preview_before_the_exact_page() {
|
||||
let mut s = holding(&[]);
|
||||
let view = [page(2, 0, 0)];
|
||||
let mut log = Vec::new();
|
||||
s.update(&view, true, recorder(&mut log));
|
||||
assert_eq!(log.len(), 1);
|
||||
assert!(!log[0].2, "a faithful document previews first");
|
||||
s.tx.send(done(view[0], log[0].1, false, 4)).unwrap();
|
||||
let mut log2 = Vec::new();
|
||||
s.update(&view, true, recorder(&mut log2));
|
||||
assert!(log2.len() == 1 && log2[0].2, "then the exact page, once every page is shown");
|
||||
let mut log3 = Vec::new();
|
||||
holding(&[]).update(&view, false, recorder(&mut log3));
|
||||
assert!(log3[0].2, "an unfaithful preview is skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_edit_re_renders_only_the_texels_it_reaches() {
|
||||
let (a, b) = (page(4, 1, 1), page(4, 3, 3));
|
||||
let mut s = holding(&[a, b]);
|
||||
// Page a: core 2048..4096 at 4 px per texel, source one texel more.
|
||||
s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111)));
|
||||
assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2112, 2112)), "whole texels");
|
||||
assert!(s.pages[&a].dirty && s.pages[&a].exact);
|
||||
// More damage on a dirty page adds to its part.
|
||||
s.invalidate(id(3), Some(DRect::new(2200, 2100, 2204, 2104)));
|
||||
assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2204, 2112)));
|
||||
let mut regions = Vec::new();
|
||||
s.update(&[a], true, |p, e, x, _, r| {
|
||||
regions.push((p, e, x, r));
|
||||
true
|
||||
});
|
||||
assert_eq!(regions, vec![(a, s.epoch(a), true, s.pages[&a].partial)]);
|
||||
// The part is written into the page held: same page, same bytes, current again.
|
||||
let mut d = done(a, regions[0].1, true, 4);
|
||||
d.region = regions[0].3;
|
||||
s.tx.send(d).unwrap();
|
||||
s.update(&[a], true, |_, _, _, _, _| panic!("nothing left to render"));
|
||||
let up = s.take_uploads(&[a], 16);
|
||||
assert_eq!(up.len(), 1);
|
||||
s.uploaded(&up[0], 0, true);
|
||||
assert!(!s.pages[&a].dirty && s.pages[&a].partial.is_none() && s.pages[&a].bytes == 100);
|
||||
assert_eq!(s.stats.partial, 1);
|
||||
// Most of a page, or damage of unknown extent, renders it whole.
|
||||
s.invalidate(id(4), Some(DRect::new(2048, 2048, 4000, 4000)));
|
||||
assert!(s.pages[&a].dirty && s.pages[&a].partial.is_none() && !s.pages[&a].exact);
|
||||
s.invalidate(id(5), None);
|
||||
assert!(s.pages[&b].partial.is_none());
|
||||
// A part of a page evicted meanwhile is dropped: the whole page is asked for.
|
||||
let mut s = holding(&[a]);
|
||||
s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111)));
|
||||
let region = s.pages[&a].partial;
|
||||
let epoch = s.epoch(a);
|
||||
s.partial_failed(a);
|
||||
let mut d = done(a, epoch, true, 4);
|
||||
d.region = region;
|
||||
s.tx.send(d).unwrap();
|
||||
let mut log = Vec::new();
|
||||
s.update(&[a], true, recorder(&mut log));
|
||||
assert!(s.ready.is_empty());
|
||||
assert_eq!(log.len(), 1, "the whole page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploads_per_frame_are_bounded() {
|
||||
let mut s = holding(&[]);
|
||||
let view: Vec<PageId> = (0..4).map(|x| page(1, x, 0)).collect();
|
||||
for p in &view {
|
||||
s.ready.push(done(*p, 0, true, 514));
|
||||
}
|
||||
// 514² texels of 16 bytes: 4.2 MB each.
|
||||
let first = s.take_uploads(&view, 16);
|
||||
assert_eq!(first.len(), UPLOADS_PER_FRAME);
|
||||
assert_eq!(first[0].page, view[0], "in view order");
|
||||
// A page over the byte cap still goes alone.
|
||||
let big = s.take_uploads(&view, 1 << 10);
|
||||
assert_eq!(big.len(), 1);
|
||||
assert_eq!(s.ready.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_evicts_cold_pages_but_never_wanted_ones() {
|
||||
let held: Vec<PageId> = (0..4).map(|x| page(1, x, 0)).collect();
|
||||
let mut s = holding(&held);
|
||||
s.clock = 10;
|
||||
s.pages.get_mut(&held[2]).unwrap().touched = 9;
|
||||
let wanted = [held[0], held[1], page(1, 9, 9)];
|
||||
// 400 held, limit 450: room for 100 more means evicting one cold page, the oldest.
|
||||
assert_eq!(s.make_room(wanted[2], 100, 450, &wanted), Some(true));
|
||||
assert!(!s.pages.contains_key(&held[3]) && s.pages.contains_key(&held[2]));
|
||||
assert_eq!(s.make_room(wanted[2], 100, 399, &wanted), Some(true));
|
||||
assert_eq!(s.resident(), 2);
|
||||
assert_eq!(s.make_room(wanted[2], 100, 250, &wanted), None, "wanted pages are never evicted");
|
||||
assert_eq!(s.stats.evicted, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_page_falls_back_until_it_is_retried() {
|
||||
let mut s = holding(&[]);
|
||||
let view = [page(1, 0, 0)];
|
||||
let mut log = Vec::new();
|
||||
s.update(&view, true, recorder(&mut log));
|
||||
s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, region: None, result: Err("out of memory".into()) }).unwrap();
|
||||
s.update(&view, true, |_, _, _, _, _| panic!("no retry while failed"));
|
||||
assert!(s.stuck(&view));
|
||||
assert_eq!(s.stats.last_error.as_deref(), Some("out of memory"));
|
||||
s.retry_after((7, 7));
|
||||
assert!(!s.stuck(&view), "a new memory configuration retries");
|
||||
s.invalidate(id(2), None);
|
||||
assert!(!s.stuck(&view));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_drops_finer_and_dirty_pages_once_the_view_is_current() {
|
||||
let (fine, coarse, view) = (page(1, 0, 0), page(4, 0, 0), page(2, 0, 0));
|
||||
let mut s = holding(&[fine, coarse]);
|
||||
assert!(s.prune(&[view]).is_some());
|
||||
assert_eq!(s.resident(), 2, "kept until the view's own page is shown");
|
||||
s.uploaded(&done(view, s.epoch(view), true, 4), 100, true);
|
||||
let ids = s.prune(&[view]).expect("changed");
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(!s.pages.contains_key(&fine) && s.pages.contains_key(&coarse));
|
||||
assert!(s.prune(&[view]).is_none(), "unchanged: nothing to tell the GPU");
|
||||
assert!(s.epochs.is_empty(), "epochs only for work in flight");
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) {
|
||||
app.gpu_fault = Some(fault.clone());
|
||||
// Canvas caches pointed at GPU textures: rebuild them as egui textures.
|
||||
app.canvases.clear();
|
||||
// Viewport pages lived on the GPU too: their work stops, and the CPU canvas draws instead.
|
||||
app.streams.clear();
|
||||
app.proxy_uploaded = None;
|
||||
app.prefs_rt.gpu_style = None;
|
||||
app.perf.gpu = false;
|
||||
|
||||
@@ -350,6 +350,8 @@ pub struct PhotocraftApp {
|
||||
navigator_gpu: HashMap<DocId, crate::gpu_canvas::ViewParams>,
|
||||
/// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`).
|
||||
navigator_plain: std::collections::HashSet<DocId>,
|
||||
/// Viewport pages of documents too large for their GPU texture set (`canvas::viewport_pages`).
|
||||
streams: HashMap<DocId, canvas::viewport_pages::Stream>,
|
||||
/// The Info panel's Color Sampler readouts for (document, revision) (`panels::sampler_readouts`).
|
||||
info_samplers: Option<panels::SamplerReadouts>,
|
||||
/// Display profile readings (#569).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Viewport pages (GPU canvas for over-budget documents)
|
||||
|
||||
A document's display texture set (every tile, every mip level) is held to the per-document
|
||||
canvas budget (`gpu_canvas::budget::canvas_budget`: a quarter of physical memory, capped by a
|
||||
discrete GPU's own memory, from the detected RAM and VRAM, so also with GPU Memory on Auto).
|
||||
A document whose texture set fits keeps it, with everything around it unchanged: the layer
|
||||
budget (LayerFit), deferred refreshes (`DEFER_PX`), background redraw and the compositor's
|
||||
output admission. There is no pixel-count cutoff.
|
||||
|
||||
A document whose texture set doesn't fit used to fall back to the CPU canvas
|
||||
(`canvasTooLarge`). It is now drawn on the GPU canvas from viewport pages
|
||||
(`ui-egui/src/canvas/viewport_pages.rs`, `gpu_canvas/pages.rs`):
|
||||
|
||||
- Pages are 512 texels square at a power-of-two reduction chosen so a texel covers at most one
|
||||
screen pixel; a view asks for at most 256 pages and coarsens beyond that. Each page is rendered
|
||||
from its core plus a one-texel apron (`photocraft_compose::viewport`), so independently rendered
|
||||
pages filter their shared edges identically; the shader discards the apron.
|
||||
- Two background workers render pages (over all documents), centre of the view first. At a
|
||||
reduction, a faithful document shows a quick nearest-sample preview of every page in view first,
|
||||
then the exact (area-averaged) page. Work for a page that left the view, changed zoom or was
|
||||
edited is cancelled, and a late result of older work is dropped.
|
||||
- Uploads are bounded per frame (2 pages, 32 MB of texels), so staging stays bounded however
|
||||
many pages finish at once.
|
||||
- Pages are held to half the per-document canvas budget (the rest is headroom for the
|
||||
compositor); the least recently wanted pages are evicted first and wanted pages never are.
|
||||
The global wgpu resource budget and the split of the GPU memory budget between documents are
|
||||
not changed.
|
||||
- An edit dirties only the pages its damage (plus the layers' effect reach) touches; a dirty page
|
||||
stays on screen until its replacement is uploaded. Invalidation costs the pages held, not the
|
||||
document's layers or tiles.
|
||||
- When no page fits the budget, or a page fails to render or upload, the document is drawn on the
|
||||
CPU canvas as before (`canvasTooLarge`). A failed page is retried when the scratch storage or
|
||||
memory configuration changes. Closing the document, a GPU fault or dropping the GPU canvas
|
||||
cancels its work and frees its pages. Pattern Preview and size-changing previews keep main's
|
||||
path.
|
||||
Reference in new issue
Block a user