mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 14:43:28 +00:00
Imported documents need durable conversion evidence and native pixel operations before XCF conversion can produce editable PhotoCraft data. This adds bounded document/report types, lossless pattern construction and `.pcraft` persistence, plus native HDR, percentile, noise, convolution, Average, coverage and matting kernels and checked Smart-filter mixing. Runtime color models/depths are retained; source attachments remain inert. Native SVG operations retain parsing, lowering, rendering and parser-tree destruction on one bounded worker, including nested resources; WebAssembly reports a controlled error for depths requiring native isolation. Native filter corrections cover matte black points and signed HDR, extent-wide Smart-filter coverage, flat-image identity Auto Levels, opaque FillHoles capability, exact unselected channel preservation, coverage-preserving Average selection and bounded pattern expansion. Checked Smart-filter mixing rejects nonfinite native F32 samples, preserves unchanged Normal pixels without regenerating CMYK separations, and recovers representable alpha mixtures after an overflowing difference. HDR blend overflow retries use cold F64 arithmetic while retaining ordinary finite results and integer-depth behavior. Both opaque and alpha-bearing F32 mask recovery retain signed/HDR colors. Stored pattern declarations remain lossless and inert. Dense PSD/PAT/ASL pattern export now checks dimensions and one shared plane-allocation budget before conversion. PAT/ASL report conversion errors; PSD/PSB report named omission warnings. Matching raw PSD pattern blocks remain verbatim. CMYK offsets retain the established brightness convention. Command registration, XCF decoding and frontend workflows follow in dependent parts. The contributor entry is the author's existing chosen entry. <!-- native-foundations-fixes-20261107:start --> Small noise, convolution and point-operation edits admit their clipped work area on large owners while retaining output/workspace limits and whole-owner FillHoles bounds. HDR toning retains skipped transparent native samples. Normal Smart-filter strength interpolates stored CMYK/Lab/ink planes with premultiplied coverage, preserving unchanged channels. Bitmap Custom Pattern reads compact patterns in bounded row bands and ranks the complete map once. Embedded SVG isolation failures propagate independently of strict mode; ordinary invalid images can still be omitted, and private native parses share the complete-operation worker. The import-report regression now primes the layer blob cache and requires its specific limit diagnostic. <!-- native-foundations-fixes-20261107:end --> <!-- adversarial-5810371597:start --> Import-report accounting uses portable fixed costs. HDR scene means use F64. Bitmap pattern ranking preflights a 256 MiB input/order/output budget, uses fallible allocation and heap-scratch-free sorting. Average uses the supplied image domain and retains one global mean across output tiles. Non-Normal Smart-filter coverage is classified once across the complete mixing extent. A fallible filter dispatcher and engine error conversion preserve checked native diagnostics and cancellation. Smart-filter engine integration remains in part 5 (#11032). <!-- adversarial-5810371597:end --> ## Six-part series Each branch is hosted in Terra854/photocraft, and every PR targets storytold/photocraft:main. Merge in the order below. Later drafts currently show cumulative diffs while prerequisites are unmerged; their descriptions link to isolated comparisons. Rebase each part onto current upstream main after its prerequisites merge, including squash-merge ancestry, and rerun affected checks before requesting review. | Part | Upstream PR | Fork branch | Scope | | --- | --- | --- | --- | | 1 | [#11009](https://github.com/storytold/photocraft/pull/11009) | xcf-support-v2-01-import-data | Document/native-format data and pixel foundations | | 2 | [#11029](https://github.com/storytold/photocraft/pull/11029) | xcf-support-v2-02-decoder | Bounded transport/records, precision, metadata, editable text and paths | | 3 | [#11030](https://github.com/storytold/photocraft/pull/11030) | xcf-support-v2-03-effects-core | Native effect frames and primary tone/color/spatial/resource/transform adapters | | 4 | [#11031](https://github.com/storytold/photocraft/pull/11031) | xcf-support-v2-04-effects-graphs | Remaining adapters, bounded graph lowering, coverage installation and source evidence | | 5 | [#11032](https://github.com/storytold/photocraft/pull/11032) | xcf-support-v2-05-engine | Native commands, import preparation, historical choices, editing and persistence workflows | | 6 | [#11033](https://github.com/storytold/photocraft/pull/11033) | xcf-support-v2-06-frontends | Desktop/web UI, CLI/MCP, translations, GPU diagnostics and qualification/docs | The full integration remains preserved on `xcf-support-v2`; original #7098 remains available for reference. ## Validation <!-- main-catchup-0d08cb7dd:start --> Updated to upstream `main` at `0d08cb7dd` and restacked all six branches. Part 1 is `640a01f43`; full integration is `24cd17301`. The final six-part tree exactly matches the full integration tree. The merge preserves both upstream's new tests/crop translations and the XCF historical-open tests/report labels. The automation adapter now preserves import-source protection across multiple granted read roots: relative source names cover every matching candidate so newly shadowing files cannot expose the original to overwrite, while absolute source names remain precise. Existing ordered reads and implicit-save provenance are retained. Read-only import source identity is kept independently of whether its library root grants an implicit Save target. Focused regressions cover source aliases, removed files, denied candidates, changing root precedence, and read-only library imports; the Windows escape fixture also uses a real junction when symlink privileges are unavailable. The new upstream UI import fixture also uses the native import outcome type. Local validation passed affected host all-target checks, core and native frontend/automation unit and integration suites, forced-Vulkan GPU unit and integration suites, pinned IO corpora, strict all-target Clippy, formatting, layering, panel command dispatch, WASM, adversarial command tests, and the quick CPU performance harness. Offscreen historical-XCF prompt and conversion-report captures were reviewed. The first two PR boundaries also passed independent all-target engine checks. Tests used the pinned craft-fonts input, four Rayon workers, serial tests for large fixtures and opt-level=0 for engine/UI test glue; pixel/format crates retained their repository settings. Quick mode uses small documents and does not enforce full-size budgets. The part 1 push triggered fresh [CI](https://github.com/storytold/photocraft/actions/runs/42885527754) and [SonarCloud](https://github.com/storytold/photocraft/actions/runs/42885527740) runs. After upstream approval, CI passed the Linux gate, Windows unit/integration tests, no-HEIF tests, pinned corpora and web build. SonarCloud's instrumented archive, coverage shards and actual scan remain skipped behind its token guard; its outer green status does not mean coverage ran. Catching up with main therefore retriggered normal tests but did not remove that SonarCloud skip. Older validation below remains evidence for its explicitly named earlier commits. <!-- main-catchup-0d08cb7dd:end --> <!-- average-no-extent-4517477902:start --> Average no-extent follow-up at part 1 `f8ddb626f` (full integration `882def329`), carried through all six branches. The checked Average dispatcher now uses the requested `area` when no owner extent is supplied; an explicit extent retains its existing clipping behavior. Other filters, the Average pixel kernel and resource admission are unchanged. The new regression fails against the preceding revision and passes with the fix. It covers `apply`, one-pixel explicit tiling, and the cancellable tiled API at 8/16/32-bit, signed/HDR colors, hidden RGB, native alpha preservation, unchanged source data and pixels outside the requested area. Validation passed the algorithm unit/integration suite (935 tests; 27 existing ignored), strict all-target algorithm Clippy, formatting, layering, WASM (24 crates plus HEIF), and independent all-target engine checks of the first two PR boundaries. The broader suites below remain evidence for their named earlier commits and were not rerun for this dispatcher-only follow-up. <!-- average-no-extent-4517477902:end --> <!-- review-20261110:start --> Review follow-up at part 1 `a3144d559` (full integration `b5fd3e211`), carried through all six branches. Average admits long thin regions within its existing pixel/workspace budgets, clamps finite selection coverage, and uses owner extents for off-canvas replay. HDR Toning also adapts over the owner extent. Pattern GPU uploads now use bounded bands; the warning-free `document_to_psd` API returns `Result` on pattern export failure and reuses prepared pattern blocks. Warning-enabled exports retain explicit omission warnings. Five filter regressions fail against the previous revision and pass with the fixes. Local validation passed 1,597 algorithm/compositor/GPU tests with Vulkan, all 129 GPU integration tests separately on DX12 with FXC (the app uses FXC when no bundled DXC is present), a separate forced DX12 pattern upload/readback test, 1,144 IO unit and 675 pinned IO integration tests, 10 affected engine tests, strict all-target Clippy, formatting, layering, WASM and quick CPU performance checks. The first two PR boundaries passed independent all-target engine checks. This Intel driver's DX12/DXC configuration rejects the unchanged `fs_adjust` pipeline (`0x80070057`); DX12/FXC and Vulkan both pass. The full workspace suite was not rerun; earlier broad evidence below remains tied to its named commits. Quick performance mode does not enforce full-size budgets. A separate 24 MP signed/HDR RGBA32F public Average-kernel probe retained exact mean RGB and original alpha. Three alternating runs had medians of 1,274.713 ms before and 1,259.885 ms after; this small difference is within run variation and is not a speedup claim. The Minor/Poor-tradeoff report-deserialization finding remains open and documented. Encoded records are capped at 32 MiB and individual arrays are bounded, but compact targets can still amplify transient decoded memory before aggregate validation rejects the report. Early shared-budget deserialization is not implemented in this follow-up. <!-- review-20261110:end --> <!-- main-catchup-7c64653d5:start --> Updated to upstream `main` at `7c64653d5` and restacked all six branches. Part 1 is `cbf068d5f`; full integration is `206f71012`. The full integration tree exactly matches part 6. The merge retains main's alpha-preserving Average behavior with bounded checked processing, and uses it consistently for XCF coverage conversion and native replay. Historical XCF normalization and import preparation now run through main's resumable Place flow and Open as Smart Object; regression tests cover source byte order and native Edit Contents. XCF report/filter labels also cover the seven languages main promoted to menu-complete status. The current tree passed affected host all-target checks, core/IO, GPU, engine and frontend suites, strict all-target Clippy, formatting, layering, panel command dispatch, WASM and the web build, adversarial command tests, pinned IO corpora and the quick CPU performance harness. Offscreen historical-XCF prompt and conversion-report captures were reviewed. The first two PR boundaries also passed independent all-target engine checks. Tests used CI-pinned craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, four Rayon workers, serial tests for RAM-heavy suites and opt-level=0 for engine/UI test glue; pixel and format crates kept repository settings. Quick performance mode uses smaller documents and does not enforce full-size budgets. Older measurements below remain evidence for their named earlier commits. <!-- main-catchup-7c64653d5:end --> <!-- percentile-owner-4502952267:start --> Percentile owner-bounds follow-up at part 1 `93946db04` (full integration `f7264af14`): nonempty owner bounds now supply edge samples without a dense whole-owner admission check. The unchanged preflight still bounds clipped output, padded halo, workspace and CPU work; dense-image adapter limits remain in place. Two new regressions failed with `InvalidGeometry` before the fix and pass afterward, covering small edits on 81 MP and wider sparse owners, clamped-edge oracles, fractional selections, source preservation, clipped requests, invalid domains, oversized output, work limits and cancellation. This follow-up passed 903 algorithm tests (887 unit + 16 integration; 27 existing ignored), all-target strict algorithm Clippy, formatting, layering and WASM (24 crates plus the HEIF configuration). Only sampling-bound admission changed; percentile processing and resource-preflight code are byte-for-byte unchanged. The broader qualification below remains tied to its explicitly named earlier commits; the full workspace suite was not rerun for this one-file follow-up. <!-- percentile-owner-4502952267:end --> <!-- native-foundations-validation-20261107:start --> The production fixes at part 1 `c16e274b8` (full integration `6f8349cd5`) passed 7,245 affected algorithm/compositor/document/engine/native-format/IO tests across 46 binaries, with 65 existing ignored tests, plus doctests, affected all-target strict Clippy, layering, WASM, adversarial command tests, formatting and the pinned IO corpus (1,107 unit + 661 integration tests, 26 existing ignored). The final diagnostics regressions also passed independently on the foundation and full integration branches. The first PR passed an independent all-target engine check. Tests used four Rayon/test workers and the existing CI font pin; engine test glue used opt-level=0, and the foundation-only diagnostic probe also used opt-level=0 for its algorithm dependency. The full integration tree exactly matches the final sixth branch. Paired 24 MP release probes used three timed repetitions per version after warm-ups, alternating order, matching inlining controls, opaque parameters and a current public-API control, with four Rayon workers. Timings are completed operations on this host. | Probe | Before (ms) | After (ms) | Public API (ms) | | --- | ---: | ---: | ---: | | HDR toning | 830.3 | 834.4 | 791.2 | | Rgb-Normal | 1070.5 | 1069.3 | 1079.7 | | Cmyk-Normal | 683.0 | 669.7 | 691.5 | | Rgb-Multiply | 1103.1 | 1303.3 | 1297.5 | The extent-wide coverage prepass adds 200.2 ms (18.2%) in the alpha-preserving Multiply case. This is the measured cost of applying one consistent coverage rule; the Normal RGB/CMYK controls remain near baseline. The uniform HDR probe's middle-gray output changed from 0.200004 to 0.180000 (target 0.18); the public control agrees. Mixer probes check native colors and alpha at sampled positions. The quick CPU application harness also completed; its saved cross-machine comparison was skipped, and quick mode does not apply full-size budgets. No budgets or baselines changed. <!-- native-foundations-validation-20261107:end --> Earlier validation of full integration `c003e3f50`, before these follow-up fixes, completed the upstream `main` catch-up at `c101cbfb7` on 2026-11-07. The cumulative series passed affected host all-target checks, foundation and IO suites, GPU, engine and frontend suites, strict Clippy, layering, WASM, adversarial command tests, formatting, pinned IO corpora and the quick CPU performance harness. Offscreen historical-XCF prompt and conversion-report captures were reviewed. Local tests used CI-pinned craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`, four Rayon workers and temporary opt-level=0 overrides for engine/UI test glue; pixel and format crates retained repository settings. The full UI library passed 5,330 tests, and the final dialog-dispatch cleanup passed 945 focused dialog tests. The decoder and engine PR boundaries also passed independent all-target checks. The full `xcf-support-v2` integration tree matches the final sixth branch exactly. SVG stack regressions retain their 512 KiB caller, isolated complete native lifecycle and 100 retained group levels. The matched performance figures below were measured before this main integration; the HDR recovery arithmetic and bounded tile implementation are preserved. Forward filter-mask mixing retains its existing four-buffer allocation optimization and arithmetic. Its matched 24 MP RGBA32F probes measured opaque pixels at 392.410→182.842 ms, semitransparent pixels at 383.321→179.916 ms, and signed HDR pixels at 373.696→183.903 ms (five-run medians, 50–53% faster). Inverse filter-mask recovery now processes at most eight native tiles in parallel, with four reusable scratch buffers per slot and ordered surface publication. Small inputs, one-worker operation and WebAssembly use the same serial tile helper. All F64 and integer pixel arithmetic is unchanged. Bitwise regressions compare full/partial/missing tiles, negative coordinates, differing defaults and hidden/zero-alpha outputs across 30 format combinations; an explicit one-worker/two-worker comparison checks both execution paths. Matched 24 MP HDR inverse-recovery public-API probes measured 166.972→90.565 ms (opaque, 45.8% faster), and 433.325→130.707 ms (semitransparent, 69.8% faster). Five-run medians use four Rayon workers on this host; all runs assert recovered HDR samples at three positions. The existing F64 precision and HDR saturation bounds are preserved. Mask recovery retains signed/HDR colors, extreme finite values, subnormals and exact full coverage. The PSD/PSB regression uses a real blur on spatially varying HDR pixels and partial masks; raw exported channels must match the unmasked filter result, and reimported Smart caches must match independently authored masked values. The prior quick CPU application harness completed. Its saved cross-machine baseline comparison was skipped, so the paired public-API measurements above provide the direct before/after evidence. No performance budgets or baselines changed. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added image adjustments and filters for averaging, channel noise, convolution, percentile effects, alpha and coverage editing, black matte removal, HDR toning, and automatic tone, contrast, and color correction. * Added Smart-filter blending controls for opacity and blend modes. * Import-conversion information is preserved when saving and reopening documents. * **Improvements** * Improved handling of deeply nested SVGs and large patterns, with bounded pattern processing and warnings when pattern export is not possible. * Improved stability when blending extreme HDR values and applying filters to HDR images. * Improved transparency handling for channel-selective filters and added clearer feedback when image processing or export cannot be completed. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
424 lines
20 KiB
Rust
424 lines
20 KiB
Rust
use super::batch::{batch_runs, run_starts, submit_batch, tile_grid};
|
|
use super::consts::{FLUSH_BYTES, FLUSH_OPS, FLUSH_WORK, PATTERN_IDLE_FRAMES};
|
|
use super::convert::lut_texture;
|
|
use super::kit::FrameRes;
|
|
use super::paging::{OVER_BUDGET, cell_rect, first_error, grid_rect_count, grid_rects, lru_victims, scoped_work, uniform_buffer_len};
|
|
use super::textures::{ChunkOut, FxKey, ResKey, Resident, Stats};
|
|
use super::{Compositor, Document, LayerId, Plan, Rect, Surface, Unsupported, plan, timing};
|
|
|
|
impl Compositor {
|
|
/// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is
|
|
/// still open; the chunk texture is reused afterwards, so the sink must record any copies
|
|
/// or passes that read it into the given encoder. Submits the work before returning; a
|
|
/// refresh of a huge document also submits in between (the sink may be handed a new
|
|
/// encoder), so its uploads and evicted pages never pile up in memory.
|
|
pub fn render(
|
|
&mut self,
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
doc: &Document,
|
|
region: Rect,
|
|
mut sink: impl FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
|
|
) -> Result<Stats, Unsupported> {
|
|
let result = scoped_work(device, self.health.clone(), || {
|
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") });
|
|
if self.timer.as_ref().is_some_and(timing::GpuTimer::busy) {
|
|
// The readback map completes on a poll. The app polls every frame, but a
|
|
// non-blocking poll here keeps the measurements coming whatever the caller does.
|
|
match &self.health {
|
|
Some(h) => h.poll(device),
|
|
None => {
|
|
let _ = device.poll(wgpu::PollType::Poll);
|
|
}
|
|
}
|
|
}
|
|
// Whatever came back since the last render travels out with this one's stats, so
|
|
// nothing has to reach back into the compositor for it afterwards.
|
|
let landed = self.timer.as_mut().and_then(timing::GpuTimer::take);
|
|
if let Some(t) = &mut self.timer {
|
|
let r = region.intersect(&doc.bounds());
|
|
t.start(&mut encoder, r.width() as u64 * r.height() as u64);
|
|
}
|
|
let mut stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true);
|
|
if let Ok(s) = &mut stats {
|
|
s.gpu_time = landed;
|
|
}
|
|
if let Some(t) = &mut self.timer {
|
|
// A refresh that failed part way measures nothing: its spans describe work the
|
|
// caller is about to redo on the CPU.
|
|
match stats {
|
|
Ok(_) => t.finish(&mut encoder),
|
|
Err(_) => {
|
|
t.cancel();
|
|
// The refresh is lost, but a measurement that came back with it is not:
|
|
// hand it to the next render rather than drop it on the fallback path.
|
|
if let Some(landed) = landed {
|
|
t.restore(landed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let stats = stats?;
|
|
queue.submit([encoder.finish()]);
|
|
if let Some(t) = &mut self.timer {
|
|
t.submitted();
|
|
}
|
|
Ok(stats)
|
|
});
|
|
if result.is_err() {
|
|
self.discard_failed_work();
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Like [`Self::render`] but records into `encoder` without submitting.
|
|
pub fn encode(
|
|
&mut self,
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
encoder: &mut wgpu::CommandEncoder,
|
|
doc: &Document,
|
|
region: Rect,
|
|
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
|
|
) -> Result<Stats, Unsupported> {
|
|
let result = scoped_work(device, self.health.clone(), || self.encode_inner(device, queue, encoder, doc, region, sink, false));
|
|
if result.is_err() {
|
|
self.discard_failed_work();
|
|
}
|
|
result
|
|
}
|
|
|
|
fn discard_failed_work(&mut self) {
|
|
// Cache metadata advances while recording. A failed encoder may never execute its
|
|
// texture uploads or effect maps, so none of those cached pixels can be trusted.
|
|
self.prefix.clear();
|
|
self.residents.clear();
|
|
self.fx.clear();
|
|
self.patterns.clear();
|
|
self.temps.clear();
|
|
self.pool.clear();
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn encode_inner(
|
|
&mut self,
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
encoder: &mut wgpu::CommandEncoder,
|
|
doc: &Document,
|
|
region: Rect,
|
|
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
|
|
flush: bool,
|
|
) -> Result<Stats, Unsupported> {
|
|
if let Some(f) = self.fault() {
|
|
return Err(Unsupported(f.to_string()));
|
|
}
|
|
// CMYK layers convert through the document's own CMYK profile (uploads and plan colours).
|
|
let space = photocraft_compose::cmyk_space(doc);
|
|
self.cmyk = space.as_ref().map_or(0, |s| s.id);
|
|
photocraft_color::convert::with_cmyk_space(space.as_ref(), || self.encode_scoped(device, queue, encoder, doc, region, sink, flush))
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn encode_scoped(
|
|
&mut self,
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
encoder: &mut wgpu::CommandEncoder,
|
|
doc: &Document,
|
|
region: Rect,
|
|
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
|
|
flush: bool,
|
|
) -> Result<Stats, Unsupported> {
|
|
let canvas = doc.bounds();
|
|
let region = region.intersect(&canvas);
|
|
let plan = plan(doc)?;
|
|
self.check_fx(doc, &plan)?;
|
|
self.pattern_mode = doc.pixel_format().mode;
|
|
self.pattern_doc = doc.id;
|
|
let mut stats = Stats { passes: plan.passes.len(), slots: plan.slots, ..Default::default() };
|
|
if region.is_empty() {
|
|
return Ok(stats);
|
|
}
|
|
let page = self.page;
|
|
let mut cells = grid_rects(region, page);
|
|
// Check every actual page cell before uploads, submissions or sink output. A later
|
|
// cell can need more chunk records than the first; a failure must leave the device healthy.
|
|
let buffer_limit = device.limits().max_buffer_size;
|
|
for part in &cells {
|
|
uniform_buffer_len(grid_rect_count(*part, self.chunk)?, plan.passes.len(), buffer_limit)?;
|
|
}
|
|
if self.frame == 0 {
|
|
// The first submission after device creation is dropped on some drivers (RADV),
|
|
// which would render the first frame black; land it on an empty buffer instead.
|
|
queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
|
|
}
|
|
self.frame += 1;
|
|
self.stamp += 1;
|
|
self.staged = 0;
|
|
self.recorded = 0;
|
|
self.layer_alloc = None;
|
|
self.admit_freed = 0;
|
|
|
|
// Only render owns submission. A caller may discard encode's command buffer, so it
|
|
// cannot establish (or consume) a checkpoint whose GPU copy might never execute.
|
|
// Runs of plain layers drawn as one pass each (`fs_layerbatch`). The grouping is the same
|
|
// for every render of this plan, so a layer's bits don't depend on the cache: renders
|
|
// capture and resume only where a run starts.
|
|
let runs = batch_runs(&plan);
|
|
let checkpoint = if flush {
|
|
let starts = run_starts(&runs);
|
|
let resumable: Vec<_> = plan.checkpoints.iter().filter(|c| starts.get(c.pass_end).copied().unwrap_or(false)).copied().collect();
|
|
self.prefix.prepare(doc, &resumable)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Effect maps of whole regions first (they may render group shapes through sub-plans);
|
|
// regions larger than the limit are built per cell below.
|
|
let paged: Vec<bool> = plan.fx.iter().map(|f| self.fx_paged(f.region)).collect();
|
|
let aprons: Vec<i32> = plan.fx.iter().zip(&paged).map(|(f, &pg)| if pg { Self::fx_apron(doc, f) } else { 0 }).collect();
|
|
for (f, &pg) in plan.fx.iter().zip(&paged) {
|
|
if !pg {
|
|
self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, None), f.region, &mut stats)?;
|
|
}
|
|
}
|
|
let fr = self.frame_res(device, queue, canvas, &plan)?;
|
|
|
|
// Page cells; every other frame in reverse, so a repeated full refresh of a document
|
|
// larger than the budget starts with the pages still resident.
|
|
if self.frame.is_multiple_of(2) {
|
|
cells.reverse();
|
|
}
|
|
if let Some(f) = self.focus {
|
|
let p = page as i32;
|
|
let focused = |r: &Rect| !cell_rect((r.x0.div_euclid(p), r.y0.div_euclid(p)), page).intersect(&f).is_empty();
|
|
cells.sort_by_key(focused);
|
|
}
|
|
let flush_bytes = FLUSH_BYTES.min(self.memory_budget() / 8).max(16 << 20);
|
|
stats.cells = cells.len();
|
|
// The previous intermediate submit (see `submit_batch`).
|
|
let mut last_submit: Option<wgpu::SubmissionIndex> = None;
|
|
// Work recorded and bytes evicted since the last submit.
|
|
let (mut work, mut freed) = (0u64, 0u64);
|
|
for part in cells {
|
|
self.stamp += 1;
|
|
let cell = (part.x0.div_euclid(page as i32), part.y0.div_euclid(page as i32));
|
|
let cr = cell_rect(cell, page);
|
|
for (i, f) in plan.fx.iter().enumerate() {
|
|
let pf = cr.intersect(&f.region);
|
|
if !paged.get(i).copied().unwrap_or(false) || pf.is_empty() {
|
|
continue;
|
|
}
|
|
let window = pf.inflate(aprons.get(i).copied().unwrap_or(0)).intersect(&f.region);
|
|
self.sync_fx(device, queue, encoder, doc, f, (f.layer.id, Some(cell)), window, &mut stats)?;
|
|
}
|
|
let chunks = grid_rects(part, self.chunk);
|
|
stats.chunks += chunks.len();
|
|
work += part.width() as u64 * part.height() as u64 * plan.passes.len() as u64;
|
|
let mut batch = flush.then_some(&mut last_submit);
|
|
self.run_cell(device, queue, encoder, doc.id, &plan, &runs, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch);
|
|
// The device was lost in a submit inside the cell: issue no more work.
|
|
if let Some(f) = self.fault() {
|
|
return Err(Unsupported(f.to_string()));
|
|
}
|
|
// Out of memory although every page this cell doesn't use was dropped: the work recorded
|
|
// since the last submit can still hold those pages. Submit it, wait until the GPU is done
|
|
// with it so their memory is released, and run the cell once more. `encode` can't
|
|
// submit, so there the refresh goes to the CPU straight away.
|
|
if flush && self.layer_alloc.as_deref() == Some(OVER_BUDGET) {
|
|
self.layer_alloc = None;
|
|
submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit);
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let idle = match self.health.as_ref() {
|
|
Some(h) => h.wait_idle(device, last_submit.clone()),
|
|
None => device.poll(wgpu::PollType::Wait { submission_index: last_submit.clone(), timeout: None }).is_ok(),
|
|
};
|
|
// The GPU has run every timestamp submitted so far, so a retry that fails too
|
|
// can drop this measurement without switching timing off for good.
|
|
if idle && let Some(t) = self.timer.as_mut() {
|
|
t.drained();
|
|
}
|
|
}
|
|
self.staged = 0;
|
|
self.recorded = 0;
|
|
work = 0;
|
|
freed = 0;
|
|
self.admit_freed = 0;
|
|
stats.flushes += 1;
|
|
if let Some(f) = self.fault() {
|
|
return Err(Unsupported(f.to_string()));
|
|
}
|
|
let mut batch = Some(&mut last_submit);
|
|
self.run_cell(device, queue, encoder, doc.id, &plan, &runs, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch);
|
|
if let Some(f) = self.fault() {
|
|
return Err(Unsupported(f.to_string()));
|
|
}
|
|
}
|
|
// A page didn't fit even after evicting: this refresh goes to the CPU, the next one
|
|
// tries the GPU again (#6563).
|
|
if let Some(e) = self.layer_alloc.take() {
|
|
return Err(Unsupported(e));
|
|
}
|
|
let (evicted, bytes) = self.evict();
|
|
stats.evicted += evicted;
|
|
freed = freed.saturating_add(bytes.saturating_add(std::mem::take(&mut self.admit_freed)));
|
|
if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) {
|
|
submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), &mut last_submit);
|
|
self.staged = 0;
|
|
self.recorded = 0;
|
|
work = 0;
|
|
freed = 0;
|
|
stats.flushes += 1;
|
|
// The device was lost mid-refresh: issue no more work.
|
|
if let Some(f) = self.fault() {
|
|
return Err(Unsupported(f.to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Evict this document's textures whose layers are gone (hidden layers stay resident so
|
|
// toggling visibility costs no upload).
|
|
let frame = self.frame;
|
|
let live: std::collections::HashSet<LayerId> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
|
self.residents.retain(|(id, _, _), r| r.doc != doc.id || r.last_used == frame || live.contains(id));
|
|
self.fx.retain(|(id, _), e| e.doc != doc.id || e.last_used == frame || live.contains(id));
|
|
stats.evicted += self.evict().0;
|
|
self.temps.retain(|(_, used)| frame.saturating_sub(*used) < 240);
|
|
self.evict_patterns();
|
|
Ok(stats)
|
|
}
|
|
|
|
/// Drop the pattern textures no frame has painted with for [`PATTERN_IDLE_FRAMES`] frames,
|
|
/// then the least recently painted ones over the pattern budget. Never one this frame painted
|
|
/// with: it would only be uploaded again on the next frame.
|
|
fn evict_patterns(&mut self) {
|
|
let frame = self.frame;
|
|
self.patterns.retain(|_, p| frame.saturating_sub(p.last_used) < PATTERN_IDLE_FRAMES);
|
|
let mut total = self.pattern_cache_bytes();
|
|
if total > self.pattern_budget {
|
|
let mut lru: Vec<(u64, String, u64)> =
|
|
self.patterns.iter().filter(|(_, p)| p.last_used != frame).map(|(k, p)| (p.last_used, k.clone(), p.bytes)).collect();
|
|
lru.sort_unstable();
|
|
for (_, key, bytes) in lru {
|
|
if total <= self.pattern_budget {
|
|
break;
|
|
}
|
|
self.patterns.remove(&key);
|
|
total = total.saturating_sub(bytes);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keep resident pages within the resident budget and the effect cache within
|
|
/// its budget, least recently used first. Never drops what the current cell uses, nor
|
|
/// whole-region effect maps drawn this frame. Returns how many textures were dropped and
|
|
/// their bytes.
|
|
fn evict(&mut self) -> (usize, u64) {
|
|
let (mut n, mut freed) = self.evict_residents(self.resident_budget, 0, None);
|
|
let mut total = self.fx_cache_bytes();
|
|
let fx_budget = usize::try_from(self.fx_budget).unwrap_or(usize::MAX);
|
|
if total > fx_budget {
|
|
let (frame, stamp) = (self.frame, self.stamp);
|
|
let mut lru: Vec<((u64, u64), FxKey)> = self
|
|
.fx
|
|
.iter()
|
|
.filter(|(k, e)| e.last_used != frame || (k.1.is_some() && e.stamp != stamp))
|
|
.map(|(k, e)| ((e.last_used, e.stamp), *k))
|
|
.collect();
|
|
lru.sort_unstable_by_key(|c| c.0);
|
|
for (_, k) in lru {
|
|
if total <= fx_budget {
|
|
break;
|
|
}
|
|
if let Some(e) = self.fx.remove(&k) {
|
|
total = total.saturating_sub(e.bytes());
|
|
n += 1;
|
|
freed += e.bytes() as u64;
|
|
}
|
|
}
|
|
}
|
|
(n, freed)
|
|
}
|
|
|
|
/// Drop least recently used resident pages until `incoming` more bytes fit in `budget`.
|
|
/// Never drops what the current cell uses, nor `keep`. Returns how many pages were dropped
|
|
/// and their bytes.
|
|
fn evict_residents(&mut self, budget: u64, incoming: u64, keep: Option<ResKey>) -> (usize, u64) {
|
|
let stamp = self.stamp;
|
|
let pages: Vec<(u64, ResKey, u64)> =
|
|
self.residents.iter().filter(|(k, r)| r.stamp != stamp && Some(**k) != keep).map(|(k, r)| (r.stamp, *k, r.bytes())).collect();
|
|
let (mut n, mut freed) = (0, 0u64);
|
|
for k in lru_victims(pages, self.resident_bytes(), incoming, budget) {
|
|
if let Some(r) = self.residents.remove(&k) {
|
|
n += 1;
|
|
freed += r.bytes();
|
|
}
|
|
}
|
|
(n, freed)
|
|
}
|
|
|
|
/// Create the page texture for `key`, `bytes` large. Least recently used pages make room in
|
|
/// the budget first; when the device is still out of memory, every page the current cell
|
|
/// doesn't use is dropped and creation is tried once more (#6563). `None` (with
|
|
/// `layer_alloc` set) when it still fails: `render` submits the recorded work, which can still
|
|
/// hold the dropped pages, and runs the cell once more; after that the CPU draws the refresh.
|
|
pub(crate) fn create_layer_texture(
|
|
&mut self,
|
|
device: &wgpu::Device,
|
|
desc: &wgpu::TextureDescriptor<'_>,
|
|
key: ResKey,
|
|
bytes: u64,
|
|
stats: &mut Stats,
|
|
) -> Option<wgpu::Texture> {
|
|
let replaced = self.residents.get(&key).map_or(0, Resident::bytes);
|
|
let (n, dropped) = self.evict_residents(self.resident_budget, bytes.saturating_sub(replaced), Some(key));
|
|
stats.evicted += n;
|
|
self.admit_freed = self.admit_freed.saturating_add(dropped);
|
|
// WebGPU reports errors asynchronously: its failures reach the health callbacks.
|
|
if cfg!(target_arch = "wasm32") {
|
|
return Some(device.create_texture(desc));
|
|
}
|
|
for retry in [false, true] {
|
|
let (texture, mut error) = first_error(device, || device.create_texture(desc));
|
|
if error.is_none() && self.fail_allocs > 0 {
|
|
self.fail_allocs -= 1;
|
|
error = Some(wgpu::Error::OutOfMemory { source: "out of memory (test hook)".into() });
|
|
}
|
|
match error {
|
|
None => return Some(texture),
|
|
Some(wgpu::Error::OutOfMemory { .. }) if !retry => {
|
|
let (n, dropped) = self.evict_residents(0, 0, Some(key));
|
|
stats.evicted += n;
|
|
self.admit_freed = self.admit_freed.saturating_add(dropped);
|
|
}
|
|
Some(wgpu::Error::OutOfMemory { .. }) => {
|
|
self.layer_alloc = Some(OVER_BUDGET.into());
|
|
return None;
|
|
}
|
|
Some(e) => {
|
|
self.layer_alloc = Some(format!("GPU layer texture failed: {e}"));
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
self.layer_alloc = Some(OVER_BUDGET.into());
|
|
None
|
|
}
|
|
|
|
/// Per-frame resources of `plan`: the tile area of each pass's surfaces, its LUT and pattern.
|
|
fn frame_res(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, canvas: Rect, plan: &Plan<'_>) -> Result<FrameRes, Unsupported> {
|
|
let grid = tile_grid(canvas);
|
|
let area = |s: &Surface| s.tile_bounds().intersect(&grid);
|
|
let areas = plan
|
|
.passes
|
|
.iter()
|
|
.map(|p| (p.tex.as_ref().map_or(Rect::EMPTY, |t| area(t.surface.get())), p.mask.as_ref().map_or(Rect::EMPTY, |m| area(m.surface.get()))))
|
|
.collect();
|
|
let luts = plan.passes.iter().map(|p| p.lut.as_ref().map(|rows| lut_texture(device, queue, rows))).collect();
|
|
let patterns = plan.passes.iter().map(|p| p.pattern.map(|pat| self.pattern_view(device, queue, pat)).transpose()).collect::<Result<_, _>>()?;
|
|
Ok(FrameRes { areas, luts, patterns })
|
|
}
|
|
}
|