Files
photocraft/crates/gpu/src/cells.rs
T
Terra854 453ea8c9ca [Extend XCF import into native PhotoCraft documents 1/6] Persist import conversion data and bounded native filter foundations
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 -->
2026-11-11 07:35:40 +01:00

661 lines
32 KiB
Rust

use super::batch::{batch_kernel, batch_op_words, op_words, submit_batch};
use super::consts::{FLUSH_OPS, MAP32, STRIDE};
use super::convert::{convert_tile, convert_tiles, keep_overlap, put_tiles, words};
use super::kit::{Bound, FrameRes, texture_group, uniform_group};
use super::paging::cell_rect;
use super::textures::{CachedPattern, Cell, ChunkOut, ResKey, Resident, ResidentRef, Stats, Tex, TexKind};
use super::{Arc, Compositor, DocId, HashMap, I, Kernel, Pattern, Plan, Rect, Role, Surface, TILE_SIZE, TileCoord, Unsupported, plan};
/// Each of the RGBA and encoded upload buffers is bounded independently, rather
/// than retaining two expanded copies of a compact pattern's entire texture.
const PATTERN_UPLOAD_BAND_BYTES: u64 = 4 << 20;
fn pattern_upload_bands(
pat: &Pattern,
mode: photocraft_color::ColorMode,
max_band_bytes: u64,
mut upload: impl FnMut(u32, u32, &[u8]),
) -> Result<(), Unsupported> {
let mut bytes = Vec::new();
photocraft_compose::pattern::pattern_rgba_bands(pat, mode, max_band_bytes, |top, pixels| {
let len = pixels.len().checked_mul(16)?;
bytes.clear();
bytes.try_reserve_exact(len).ok()?;
bytes.extend(pixels.iter().flat_map(|q| [q[0] * q[3], q[1] * q[3], q[2] * q[3], q[3]]).flat_map(f32::to_le_bytes));
let rows = u32::try_from(pixels.len() / pat.width as usize).ok()?;
upload(top, rows, &bytes);
Some(())
})
.ok_or_else(|| Unsupported(format!("pattern `{}` could not be expanded within the upload scratch budget", pat.name)))
}
fn write_pattern_band(queue: &wgpu::Queue, texture: &wgpu::Texture, width: u32, top: u32, rows: u32, bytes: &[u8]) {
queue.write_texture(
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x: 0, y: top, z: 0 }, aspect: wgpu::TextureAspect::All },
bytes,
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(width * 16), rows_per_image: Some(rows) },
wgpu::Extent3d { width, height: rows, depth_or_array_layers: 1 },
);
}
impl Compositor {
/// Bring the pages of `cell` that `plan` samples up to date and resolve its effect maps.
#[allow(clippy::too_many_arguments)]
fn bind_cell(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
doc: DocId,
plan: &Plan<'_>,
paged: &[bool],
cell: Cell,
fr: &FrameRes,
first_pass: usize,
stats: &mut Stats,
) -> Bound {
let cr = cell_rect(cell, self.page);
let mut views: Vec<(Option<ResidentRef>, Option<ResidentRef>)> = Vec::with_capacity(plan.passes.len());
let mut keys: Vec<ResKey> = Vec::new();
let mut maps = Vec::with_capacity(plan.passes.len());
for (i, p) in plan.passes.iter().enumerate() {
if i < first_pass {
views.push((None, None));
maps.push(None);
continue;
}
let (ta, ma) = fr.areas.get(i).copied().unwrap_or((Rect::EMPTY, Rect::EMPTY));
let tex =
p.tex.as_ref().and_then(|t| self.sync(device, queue, encoder, doc, (t.layer, t.role, cell), t.surface.get(), ta.intersect(&cr), stats)).map(
|(k, r)| {
keys.push(k);
(keys.len() - 1, r)
},
);
let mask = p
.mask
.as_ref()
.and_then(|m| self.sync(device, queue, encoder, doc, (m.layer, Role::Mask, cell), m.surface.get(), ma.intersect(&cr), stats))
.map(|(k, r)| {
keys.push(k);
(keys.len() - 1, r)
});
views.push((tex, mask));
maps.push(p.map.and_then(|m| {
let f = plan.fx.get(m.fx)?;
let key = (f.layer.id, paged.get(m.fx).copied().unwrap_or(false).then_some(cell));
let e = self.fx.get(&key)?;
if m.item == plan::SHAPE_MAP {
return Some((e.shape.view.clone(), e.region));
}
let t = e.progs.get(m.item)?.maps.get(m.map)?.as_ref()?;
Some((t.view.clone(), e.region))
}));
}
Bound { views, keys, maps }
}
/// Run `plan` (drawn as `runs`, its `batch_runs`) over the `chunks` of one page cell
/// (document rects, at most a chunk square), handing each finished chunk to `sink`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_cell(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
doc: DocId,
plan: &Plan<'_>,
runs: &[usize],
paged: &[bool],
cell: Cell,
fr: &FrameRes,
chunks: &[Rect],
checkpoint: Option<plan::Checkpoint>,
stats: &mut Stats,
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
// The previous intermediate submit, when this refresh may submit in between.
batch: &mut Option<&mut Option<wgpu::SubmissionIndex>>,
) {
if chunks.is_empty() {
return;
}
// Chunks may hold different checkpoints: bind from the lowest one any of them resumes at.
let first_pass = checkpoint
.and_then(|_| chunks.iter().map(|c| self.prefix.restore_point(*c).map(|r| r.pass_end)).try_fold(usize::MAX, |m, p| Some(m.min(p?))))
.unwrap_or(0);
let bound = self.bind_cell(device, queue, encoder, doc, plan, paged, cell, fr, first_pass, stats);
// A page couldn't be created: draw nothing with the pages missing (the caller ends the refresh).
if self.layer_alloc.is_some() {
return;
}
// Chunk pool.
while self.pool.len() < plan.slots as usize {
let t = device.create_texture(&wgpu::TextureDescriptor {
label: Some("pc_compose_chunk"),
size: wgpu::Extent3d { width: self.chunk, height: self.chunk, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.acc_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let v = t.create_view(&Default::default());
self.pool.push((t, v));
}
// Uniforms: chunk records, then one record per pass. encode_scoped preflight checked
// the length, device capacity and dynamic offsets before any GPU work for this frame.
let op_base = chunks.len() as u64 * STRIDE;
let mut data = vec![0u8; (op_base + plan.passes.len() as u64 * STRIDE) as usize];
for (i, c) in chunks.iter().enumerate() {
let w = words(&[I(c.x0), I(c.y0), I(c.width() as i32), I(c.height() as i32)]);
data[i * STRIDE as usize..][..w.len()].copy_from_slice(&w);
}
for (i, p) in plan.passes.iter().enumerate() {
if i < first_pass {
continue;
}
let (tex, mask) = bound.views[i];
let map = bound.maps[i].as_ref().map(|(_, r)| *r);
let run = runs.get(i).copied().unwrap_or(1);
let w = if run > 1 {
let extras: Vec<_> = (i + 1..i + run).filter_map(|j| Some((plan.passes.get(j)?, bound.views.get(j).and_then(|v| v.0).map(|t| t.1)))).collect();
batch_op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), &extras)
} else {
op_words(p, tex.map(|t| t.1), mask.map(|m| m.1), map)
};
let off = (op_base + i as u64 * STRIDE) as usize;
data[off..][..w.len()].copy_from_slice(&w);
}
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("pc_compose_uniforms"),
size: data.len() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&ubuf, 0, &data);
let bg0 = uniform_group(device, &self.kit.bgl0, &ubuf);
// Per-pass texture bind groups (chunk-independent: pool slots are reused per chunk).
let dummy = &self.kit.dummy;
let resident_views: Vec<&wgpu::TextureView> = bound.keys.iter().map(|k| self.residents.get(k).map_or(dummy, |r| &r.view)).collect();
let mut bg1 = Vec::with_capacity(plan.passes.len());
for (i, p) in plan.passes.iter().enumerate() {
if i < first_pass || p.kernel.entry().is_none() {
bg1.push(None);
continue;
}
let slot = |s: Option<u32>| s.map_or(dummy, |s| &self.pool[s as usize].1);
let (tex, mask) = &bound.views[i];
let tv = tex.map_or(slot(p.d), |(k, _)| resident_views[k]);
let mv = mask.map_or(dummy, |(k, _)| resident_views[k]);
let lv = fr.luts.get(i).and_then(Option::as_ref).map_or(dummy, |(_, v)| v);
let map = bound.maps[i].as_ref().map_or(dummy, |(v, _)| v);
let pat = fr.patterns.get(i).and_then(Option::as_ref).unwrap_or(dummy);
let run = runs.get(i).copied().unwrap_or(1);
if run > 1 {
// The extra layers' pixels in tex_b, tex_c and map_tex (`batch_layer`).
let extra = |e: usize| bound.views.get(i + e).and_then(|v| v.0).map_or(dummy, |(k, _)| resident_views[k]);
bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), extra(1), tv, mv, lv, extra(2), extra(3), pat])));
continue;
}
bg1.push(Some(texture_group(device, &self.kit.bgl1, &[slot(p.a), slot(p.b), tv, mv, lv, slot(p.c), map, pat])));
}
for (ci, c) in chunks.iter().enumerate() {
let mut resume = 0;
if let Some(cp) = checkpoint.and_then(|_| self.prefix.restore_point(*c))
&& let Some((dst, _)) = self.pool.get(cp.slot as usize)
&& self.prefix.copy_out(encoder, *c, dst)
{
resume = cp.pass_end;
stats.prefix_hits = stats.prefix_hits.saturating_add(1);
stats.prefix_passes_skipped = stats.prefix_passes_skipped.saturating_add(resume);
}
// Passes drawn inside the previous `fs_layerbatch` pass.
let mut batched_to = 0;
for (i, p) in plan.passes.iter().enumerate() {
if i < resume || i < batched_to {
continue;
}
// Chunk textures, uniforms and bind groups outlive the submit, and the queue
// keeps the order, so a chunk may continue in the next command buffer.
if self.recorded >= FLUSH_OPS
&& let Some(last) = batch.as_deref_mut()
{
submit_batch(device, queue, encoder, self.health.as_ref(), self.timer.as_mut(), last);
self.staged = 0;
self.recorded = 0;
stats.flushes += 1;
if self.fault().is_some() {
return;
}
}
// Also after resuming from a lower checkpoint: the chunk moves up to this one.
if resume < i
&& let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i)
&& let Some((src, _)) = self.pool.get(cp.slot as usize)
{
self.prefix.capture(device, encoder, *c, src, cp);
}
let scissor = match p.clip {
Some(clip) => {
let r = clip.intersect(c);
if r.is_empty() {
continue;
}
Rect::new(r.x0 - c.x0, r.y0 - c.y0, r.x1 - c.x0, r.y1 - c.y0)
}
None => Rect::new(0, 0, c.width() as i32, c.height() as i32),
};
match p.kernel {
Kernel::CopyRect | Kernel::CopyFull => {
// The planner always gives copies a source.
let Some(a) = p.a else { continue };
let src = &self.pool[a as usize].0;
let dst = &self.pool[p.dst as usize].0;
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: src,
mip_level: 0,
origin: wgpu::Origin3d { x: scissor.x0 as u32, y: scissor.y0 as u32, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: dst,
mip_level: 0,
origin: wgpu::Origin3d { x: scissor.x0 as u32, y: scissor.y0 as u32, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d { width: scissor.width(), height: scissor.height(), depth_or_array_layers: 1 },
);
self.recorded += 1;
continue;
}
_ => {}
}
self.recorded += 1;
// A run draws as one pass into the slot its last layer writes.
let run = runs.get(i).copied().unwrap_or(1);
let (kernel, dst) = match plan.passes.get(i + run - 1) {
Some(last) if run > 1 => {
batched_to = i + run;
stats.layers_batched = stats.layers_batched.saturating_add(run - 1);
let kernel = plan.passes.get(i..i + run).map_or(Kernel::LayerBatch, batch_kernel);
if kernel == Kernel::LayerBatchNormal {
stats.normal_batches = stats.normal_batches.saturating_add(1);
}
(kernel, last.dst)
}
_ => (p.kernel, p.dst),
};
let target = &self.pool[dst as usize].1;
let clear = p.kernel == Kernel::Clear;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("pc_compose_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: if clear { wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) } else { wgpu::LoadOp::Load },
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
if clear {
continue;
}
pass.set_pipeline(&self.kit.pipelines[&(kernel, self.acc_format)]);
pass.set_scissor_rect(scissor.x0 as u32, scissor.y0 as u32, scissor.width(), scissor.height());
pass.set_bind_group(0, &bg0, &[(ci as u64 * STRIDE) as u32, (op_base + i as u64 * STRIDE) as u32]);
pass.set_bind_group(1, bg1[i].as_ref(), &[]);
pass.draw(0..3, 0..1);
}
if resume < plan.passes.len()
&& let Some(cp) = checkpoint.filter(|cp| cp.pass_end == plan.passes.len())
&& let Some((src, _)) = self.pool.get(cp.slot as usize)
{
self.prefix.capture(device, encoder, *c, src, cp);
}
let (t, v) = &self.pool[plan.root as usize];
sink(encoder, ChunkOut { rect: *c, texture: t, view: v });
}
}
/// Upload changed tiles of `surface` within `region` (its tile area inside one page cell);
/// returns the resident key and its region (x, y, w, h).
#[allow(clippy::too_many_arguments)]
fn sync(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
doc: DocId,
key: ResKey,
surface: &Surface,
region: Rect,
stats: &mut Stats,
) -> Option<(ResKey, [i32; 4])> {
// An earlier page of this cell didn't fit: the refresh ends after the cell anyway.
if region.is_empty() || self.layer_alloc.is_some() {
return None;
}
let format = surface.format();
let kind = TexKind::for_surface(key.1, format);
let cmyk = if format.mode == photocraft_color::ColorMode::Cmyk { self.cmyk } else { 0 };
let default_pixel = surface.default_pixel();
let default_bits: Vec<u32> = default_pixel.iter().map(|v| v.to_bits()).collect();
let stale = self
.residents
.get(&key)
.is_none_or(|r| r.region != region || r.kind != kind || r.format != format || r.cmyk != cmyk || r.default_bits != default_bits);
if stale {
let desc = wgpu::TextureDescriptor {
label: Some("pc_compose_layer"),
size: wgpu::Extent3d { width: region.width(), height: region.height(), depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: kind.format(),
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
};
let bytes = region.width() as u64 * region.height() as u64 * kind.bytes_per_pixel() as u64;
let texture = self.create_layer_texture(device, &desc, key, bytes, stats)?;
let view = texture.create_view(&Default::default());
let default_nonzero = default_pixel.iter().any(|v| *v != 0.0);
let mut r = Resident {
texture,
view,
region,
kind,
format,
tiles: HashMap::new(),
default_nonzero,
default_bits: default_bits.clone(),
doc,
last_used: 0,
stamp: 0,
cmyk,
ordered: false,
};
// Only the region moved (painting past a layer's tiles grows it): keep the texels the
// old texture already holds instead of converting and uploading every tile again.
let kept = self.residents.remove(&key).map_or(Rect::EMPTY, |old| keep_overlap(device, queue, &old, &mut r));
// A new layer whose tiles another resident already holds (a duplicate shares its
// source's tiles) starts as a GPU copy of that texture. Duplicating a 20 MP photo
// converted and uploaded every tile again.
let shares =
|o: &Resident| region.tiles().filter(|c| surface.tile(*c).is_some_and(|t| o.tiles.get(c).is_some_and(|ot| Arc::ptr_eq(ot, t)))).count();
let donor = self
.residents
.iter()
.filter(|(k, o)| k.1 == key.1 && o.region == region && o.kind == kind && o.format == format && o.cmyk == cmyk && o.default_bits == default_bits)
.map(|(k, o)| (shares(o), *k))
.filter(|(n, _)| *n > 0)
.max_by_key(|(n, _)| *n);
if kept.is_empty()
&& let Some((n, o)) = donor.and_then(|(n, k)| Some((n, self.residents.get_mut(&k)?)))
{
// Recorded in the frame's encoder, after the writes and copies that brought the
// donor up to date ([`keep_overlap`]'s own submission would run before those).
o.ordered = true;
encoder.copy_texture_to_texture(
o.texture.as_image_copy(),
r.texture.as_image_copy(),
wgpu::Extent3d { width: region.width(), height: region.height(), depth_or_array_layers: 1 },
);
r.tiles = o.tiles.clone();
r.ordered = true;
stats.tiles_shared += n;
} else {
// A new texture starts zeroed: write the tiles that are present, and the default
// pixel where they are missing if it isn't zero. Coordinates sharing a tile (a
// solid fill, the default) take one upload and GPU copies (#1774). Kept tiles are
// brought up to date below, like those of a resident that didn't move.
let mut present = Vec::new();
let mut missing = Vec::new();
for c in region.tiles().filter(|c| !kept.contains_rect(&c.rect())) {
match surface.tile(c) {
Some(t) => present.push((c, t.clone())),
None if default_nonzero => missing.push(c),
None => {}
}
}
if !missing.is_empty() {
let bytes = convert_tile(surface, None, kind, TileCoord::new(0, 0));
self.staged += put_tiles(device, queue, encoder, &r, &missing, &bytes);
}
let staged = &mut self.staged;
convert_tiles(surface, present, kind, |t, cs, bytes| {
*staged += put_tiles(device, queue, encoder, &r, cs, bytes);
for &c in cs {
stats.tiles_uploaded += 1;
stats.bytes_uploaded += bytes.len();
r.tiles.insert(c, t.clone());
}
});
}
// Tiles the donor held that this surface doesn't, or holds differently, are fixed up
// by the pass over the region below.
self.residents.insert(key, r);
}
// Present: inserted above when it was missing or stale.
let r = self.residents.get_mut(&key)?;
r.last_used = self.frame;
r.stamp = self.stamp;
r.doc = doc;
let mut changed = Vec::new();
let mut gone = Vec::new();
for c in region.tiles() {
match surface.tile(c) {
Some(t) => {
if !r.tiles.get(&c).is_some_and(|old| Arc::ptr_eq(old, t)) {
changed.push((c, t.clone()));
}
}
None => {
if r.tiles.remove(&c).is_some() {
gone.push(c);
}
}
}
}
if !gone.is_empty() {
// A tile that went away reads as the default pixel again.
let bytes = if r.default_nonzero {
convert_tile(surface, None, kind, TileCoord::new(0, 0))
} else {
vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * kind.bytes_per_pixel()]
};
self.staged += put_tiles(device, queue, encoder, r, &gone, &bytes);
}
let staged = &mut self.staged;
convert_tiles(surface, changed, kind, |t, cs, bytes| {
*staged += put_tiles(device, queue, encoder, r, cs, bytes);
for &c in cs {
stats.tiles_uploaded += 1;
stats.bytes_uploaded += bytes.len();
r.tiles.insert(c, t.clone());
}
});
Some((key, [region.x0, region.y0, region.width() as i32, region.height() as i32]))
}
/// Premultiplied RGBA32F texture of a pattern as it paints in the document's mode (cached by
/// pixel identity and mode).
pub(crate) fn pattern_view(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pat: &Pattern) -> Result<wgpu::TextureView, Unsupported> {
let fp = pat.surface.tiles().fold((pat.width as u64) << 32 | pat.height as u64, |acc, (c, t)| {
acc.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 20) ^ c.ty as u64
});
let mode = self.pattern_mode;
let key = format!("{}\u{0}{}\u{0}{mode:?}", pat.id, pat.name);
let format = pat.surface.format();
// Unstored tiles read the default pixel, interpreted in the source format.
let default_bits: Vec<u32> = pat.surface.default_pixel().iter().map(|v| v.to_bits()).collect();
let (doc, last_used) = (self.pattern_doc, self.frame);
if let Some(cached) = self.patterns.get_mut(&key)
&& cached.fingerprint == fp
&& cached.format == format
&& cached.default_bits == default_bits
{
cached.doc = doc;
cached.last_used = last_used;
return Ok(cached.texture.view.clone());
}
let (w, h) = (pat.width, pat.height);
let t = Tex::new(device, "pc_pattern", w, h, wgpu::TextureFormat::Rgba32Float, wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST);
pattern_upload_bands(pat, mode, PATTERN_UPLOAD_BAND_BYTES, |top, rows, bytes| {
write_pattern_band(queue, &t.texture, w, top, rows, bytes);
})?;
let v = t.view.clone();
let bytes = u64::from(w.max(1)) * u64::from(h.max(1)) * 16;
self.patterns.insert(key, CachedPattern { fingerprint: fp, format, default_bits, texture: t, doc, last_used, bytes });
Ok(v)
}
/// An R32F temporary at least `region`-sized (the smallest that fits) not in `taken`.
/// Stages only read what they wrote, inside the region, so a larger one is as good; page
/// windows of different sizes share them.
pub(crate) fn temp(&mut self, device: &wgpu::Device, region: Rect, taken: &[wgpu::TextureView]) -> wgpu::TextureView {
let (w, h) = (region.width(), region.height());
let frame = self.frame;
let fit = self
.temps
.iter_mut()
.filter(|(t, _)| {
let s = t.texture.size();
s.width >= w && s.height >= h && !taken.contains(&t.view)
})
.min_by_key(|(t, _)| t.texture.size().width as u64 * t.texture.size().height as u64);
if let Some((t, used)) = fit {
*used = frame;
return t.view.clone();
}
let t = Tex::map(device, "pc_fx_temp", region, MAP32);
let v = t.view.clone();
self.temps.push((t, frame));
v
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod pattern_upload_tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
fn fixture(sample: SampleType) -> Pattern {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
for y in 0..5 {
surface.fill_rect(Rect::new(0, y, 3, y + 1), &[y as f32 / 4., 0.25, 0.75, 0.5]);
}
Pattern { id: "upload".into(), name: "upload".into(), width: 3, height: 5, surface }
}
#[test]
fn upload_bands_keep_complete_rows_premultiplication_and_final_partial_band() {
for sample in SampleType::ALL {
let p = fixture(sample);
let mut starts = Vec::new();
let mut uploaded = Vec::new();
pattern_upload_bands(&p, ColorMode::Rgb, 3 * 2 * 16, |top, rows, bytes| {
starts.push((top, rows));
assert_eq!(bytes.len(), rows as usize * 3 * 16);
assert!(bytes.len() <= 3 * 2 * 16);
uploaded.extend_from_slice(bytes);
})
.unwrap();
assert_eq!(starts, [(0, 2), (2, 2), (4, 1)]);
let mut expected = Vec::new();
for y in 0..5 {
for x in 0..3 {
let pixel = p.surface.pixel(x, y);
expected.extend([pixel[0] * pixel[3], pixel[1] * pixel[3], pixel[2] * pixel[3], pixel[3]].into_iter().flat_map(f32::to_le_bytes));
}
}
assert_eq!(uploaded, expected);
assert!(pattern_upload_bands(&p, ColorMode::Rgb, 3 * 16 - 1, |_, _, _| panic!("a row must fit before uploading")).is_err());
let unpaintable = Pattern { width: 30_000, height: 30_000, ..p };
assert!(pattern_upload_bands(&unpaintable, ColorMode::Rgb, 3 * 2 * 16, |_, _, _| panic!("unpaintable pattern must not upload")).is_err());
}
}
#[test]
fn small_budget_bands_reach_the_real_texture_upload_boundary() {
let required = std::env::var_os("WGPU_BACKEND").is_some();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
Ok(adapter) => adapter,
Err(error) => {
assert!(!required, "requested GPU adapter unavailable: {error}");
return;
}
};
let (device, queue) = match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) {
Ok(device) => device,
Err(error) => {
assert!(!required, "requested GPU device unavailable: {error}");
return;
}
};
for sample in [SampleType::U8, SampleType::U16] {
let p = fixture(sample);
let texture =
Tex::new(&device, "pattern band test", 3, 5, wgpu::TextureFormat::Rgba32Float, wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC);
let mut calls = Vec::new();
pattern_upload_bands(&p, ColorMode::Rgb, 3 * 2 * 16, |top, rows, bytes| {
calls.push((top, rows));
write_pattern_band(&queue, &texture.texture, p.width, top, rows, bytes);
})
.unwrap();
assert_eq!(calls, [(0, 2), (2, 2), (4, 1)]);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("pattern band readback"),
size: 256 * 5,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture: &texture.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256), rows_per_image: Some(5) },
},
wgpu::Extent3d { width: 3, height: 5, depth_or_array_layers: 1 },
);
queue.submit([encoder.finish()]);
let (send, receive) = std::sync::mpsc::channel();
buffer.slice(..).map_async(wgpu::MapMode::Read, move |result| {
let _ = send.send(result);
});
device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(30)) }).unwrap();
receive.recv_timeout(std::time::Duration::from_secs(1)).unwrap().unwrap();
let data = buffer.slice(..).get_mapped_range().unwrap();
for y in 0..5 {
for x in 0..3 {
let pixel = p.surface.pixel(x, y);
let expected = [pixel[0] * pixel[3], pixel[1] * pixel[3], pixel[2] * pixel[3], pixel[3]];
for (channel, expected) in expected.into_iter().enumerate() {
let offset = y as usize * 256 + x as usize * 16 + channel * 4;
let actual = f32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
assert_eq!(actual, expected, "{sample:?} ({x}, {y}) channel {channel}");
}
}
}
drop(data);
buffer.unmap();
}
}
}