mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 12:27:38 -06:00
- LayerBlend: a plain layer is blended straight from its texture in one pass instead of a Content pass to a scratch chunk and a Blend pass. - fs_layerbatch: runs of up to four plain layers that only feed each other are drawn as one pass, reading and writing the chunk once (the uniform record grows to its 256-byte stride to hold the extra layers). - Drop shadows and outer glows alone: the merge builds the layer from its shape (F_LAYER_SHAPE), and nothing is copied back when the effect region covers the canvas. Such a layer takes 3 passes instead of 6. - fs_layerbatch_normal: runs of Normal layers use a shader with Normal's code alone. Per-pass timestamps showed the batch spent 80% of its time on the register cost of carrying every blend mode, not on the arithmetic; Normal goes through the same function in every shader, so a layer gives the same bits whichever pass draws it. P60 / P61 with the GPU compositor (8 GB budget, M4 Pro): 111 / 113 ms -> 28.2 / 28.1 ms. With the app's default Memory Usage these documents still fall back to the CPU compositor on unified memory, so the budgets stay unenforced. Output: over the GPU parity suite (1620 renders) 1038 are bit-identical to before and the rest differ by at most 1e-4/255, except one pixel one 8-bit step apart where an adjustment rounds at .5; the mean difference from the CPU reference is unchanged. New parity tests cover runs of every mode, Normal runs at 8/16/32-bit and Lab, and exterior effects at any fill.
2197 lines
96 KiB
Rust
2197 lines
96 KiB
Rust
//! Planner: walks the layer tree into a linear list of GPU passes over abstract buffer slots.
|
||
//!
|
||
//! The structure mirrors `photocraft_compose::composite_stack` / `composite_layer` /
|
||
//! `composite_atop` one to one, so both backends share Photoshop semantics (pass-through vs
|
||
//! isolated groups, clipping, masks, opacity × fill, adjustments, layer effects). Every pass
|
||
//! reads slots and writes a fresh slot; slots are recycled as soon as nothing refers to them.
|
||
//!
|
||
//! Layer effects follow `photocraft_compose::effects::composite_with_effects`: the layer's
|
||
//! effect maps (shadow / glow / satin / bevel coverage, stroke bands) are built once per layer
|
||
//! state by [`crate::fx`] and sampled here; exterior effects paint into a copy of the backdrop,
|
||
//! the layer at fill opacity takes the interior effects, and the two merge with the layer's mode
|
||
//! (or the layer merges first and the effects paint over it: `effects::premerges_layer`) and
|
||
//! opacity. Effect passes are clipped to the layer's effect region and the result is copied
|
||
//! back into the backdrop in place, so a small text layer costs only its own pixels.
|
||
|
||
use photocraft_color::BlendMode;
|
||
use photocraft_compose::adjust::{self, Transfer};
|
||
use photocraft_compose::effects::has_effects;
|
||
use photocraft_doc::adjust::{ToneSpace, lut3d_len};
|
||
use photocraft_doc::{Adjustment, Document, Effect, Fill, FxPaint, GlobalLight, Glow, Gradient, Layer, LayerContent, LayerId, Pattern, StrokePosition};
|
||
use photocraft_geom::Rect;
|
||
use photocraft_raster::Surface;
|
||
|
||
use crate::bounds;
|
||
|
||
/// Index of a chunk-sized accumulator texture.
|
||
pub type Slot = u32;
|
||
|
||
/// Pipeline (fragment entry point) of a pass.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub enum Kernel {
|
||
/// Transparent fill (no draw).
|
||
Clear,
|
||
Content,
|
||
Mask,
|
||
Blend,
|
||
/// `Content` then `Blend` in one pass: a plain layer's pixels blended straight onto A.
|
||
LayerBlend,
|
||
/// A run of consecutive `LayerBlend` passes drawn as one (never planned: the executor draws
|
||
/// it in their place, see `batch_runs`).
|
||
LayerBatch,
|
||
/// `LayerBatch` of a run whose layers all blend Normal without text gamma, outside Lab
|
||
/// documents (never planned: the executor picks it, see `batch_kernel`).
|
||
LayerBatchNormal,
|
||
Atop,
|
||
Adjust,
|
||
AdjMix,
|
||
Lerp,
|
||
/// Effect chain start: the layer at fill opacity, or an opaque copy of a clipping base.
|
||
FxInit,
|
||
/// Paint an effect through a coverage map (or the layer's alpha) into A.
|
||
FxPaint,
|
||
/// Layer + interior effects over the exterior result, then layer opacity against the backdrop.
|
||
FxMerge,
|
||
/// Outside strokes: accumulate one stroke's share (`kind` 0, premultiplied colour) or its
|
||
/// coverage (`kind` 1).
|
||
FxStroke,
|
||
/// Outside strokes: resolve the accumulated strokes over the exterior result.
|
||
FxStrokeEnd,
|
||
/// Copy slot A into `dst` (an existing slot) over the pass clip (no draw).
|
||
CopyRect,
|
||
/// Copy all of slot A into the fresh slot `dst` (no draw).
|
||
CopyFull,
|
||
// Effect-map kernels (single-channel targets over an effect region, see `fx`).
|
||
MShift,
|
||
MDilate,
|
||
MBlur,
|
||
MGlow,
|
||
MFinish,
|
||
MBevelH,
|
||
MBevelShade,
|
||
MStroke,
|
||
MBevelTex,
|
||
}
|
||
|
||
impl Kernel {
|
||
/// Fragment entry point in `compose.wgsl` (`None` for copies and clears).
|
||
pub fn entry(self) -> Option<&'static str> {
|
||
Some(match self {
|
||
Kernel::Clear | Kernel::CopyRect | Kernel::CopyFull => return None,
|
||
Kernel::Content => "fs_content",
|
||
Kernel::Mask => "fs_mask",
|
||
Kernel::Blend => "fs_blend",
|
||
Kernel::LayerBlend => "fs_layerblend",
|
||
Kernel::LayerBatch => "fs_layerbatch",
|
||
Kernel::LayerBatchNormal => "fs_layerbatch_normal",
|
||
Kernel::Atop => "fs_atop",
|
||
Kernel::Adjust => "fs_adjust",
|
||
Kernel::AdjMix => "fs_adjmix",
|
||
Kernel::Lerp => "fs_lerp",
|
||
Kernel::FxInit => "fs_fxinit",
|
||
Kernel::FxPaint => "fs_fxpaint",
|
||
Kernel::FxMerge => "fs_fxmerge",
|
||
Kernel::FxStroke => "fs_fxstroke",
|
||
Kernel::FxStrokeEnd => "fs_fxstrokeend",
|
||
Kernel::MShift => "fs_mshift",
|
||
Kernel::MDilate => "fs_mdilate",
|
||
Kernel::MBlur => "fs_mblur",
|
||
Kernel::MGlow => "fs_mglow",
|
||
Kernel::MFinish => "fs_mfinish",
|
||
Kernel::MBevelH => "fs_mbevelh",
|
||
Kernel::MBevelShade => "fs_mbevelshade",
|
||
Kernel::MStroke => "fs_mstroke",
|
||
Kernel::MBevelTex => "fs_mbeveltex",
|
||
})
|
||
}
|
||
|
||
/// Kernels that write effect maps (R32F / R16F targets) rather than RGBA accumulators.
|
||
pub fn is_map(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Kernel::MShift
|
||
| Kernel::MDilate
|
||
| Kernel::MBlur
|
||
| Kernel::MGlow
|
||
| Kernel::MFinish
|
||
| Kernel::MBevelH
|
||
| Kernel::MBevelShade
|
||
| Kernel::MStroke
|
||
| Kernel::MBevelTex
|
||
)
|
||
}
|
||
|
||
/// Every kernel with a pipeline.
|
||
pub const DRAWN: [Kernel; 24] = [
|
||
Kernel::Content,
|
||
Kernel::Mask,
|
||
Kernel::Blend,
|
||
Kernel::LayerBlend,
|
||
Kernel::LayerBatch,
|
||
Kernel::LayerBatchNormal,
|
||
Kernel::Atop,
|
||
Kernel::Adjust,
|
||
Kernel::AdjMix,
|
||
Kernel::Lerp,
|
||
Kernel::FxInit,
|
||
Kernel::FxPaint,
|
||
Kernel::FxMerge,
|
||
Kernel::FxStroke,
|
||
Kernel::FxStrokeEnd,
|
||
Kernel::MShift,
|
||
Kernel::MDilate,
|
||
Kernel::MBlur,
|
||
Kernel::MGlow,
|
||
Kernel::MFinish,
|
||
Kernel::MBevelH,
|
||
Kernel::MBevelShade,
|
||
Kernel::MStroke,
|
||
Kernel::MBevelTex,
|
||
];
|
||
}
|
||
|
||
/// Which resident texture a pass samples.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub enum Role {
|
||
Content,
|
||
Mask,
|
||
/// A stroked shape's stroke alone (its fill uses `Content`).
|
||
Stroke,
|
||
}
|
||
|
||
/// A surface the pass needs on the GPU.
|
||
#[derive(Clone, Debug)]
|
||
pub struct TexUse<'a> {
|
||
pub layer: LayerId,
|
||
pub role: Role,
|
||
pub surface: SurfaceRef<'a>,
|
||
}
|
||
|
||
/// An effect map sampled by a pass: map `map` of enabled effect `item` of `plan.fx[fx]`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct MapRef {
|
||
pub fx: usize,
|
||
pub item: usize,
|
||
pub map: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Pass<'a> {
|
||
pub kernel: Kernel,
|
||
pub dst: Slot,
|
||
pub a: Option<Slot>,
|
||
pub b: Option<Slot>,
|
||
pub c: Option<Slot>,
|
||
/// A fourth slot, bound in place of `tex` (passes without layer pixels).
|
||
pub d: Option<Slot>,
|
||
pub mode: BlendMode,
|
||
pub opacity: f32,
|
||
/// The layer's Fill where it isn't folded into `opacity`: the final blend of a plain layer
|
||
/// (`Kernel::Blend`), so the special eight can apply it inside the blend. 1 elsewhere.
|
||
pub fill: f32,
|
||
/// Layer pixels (raster / text / shape / smart cache / fill cache).
|
||
pub tex: Option<TexUse<'a>>,
|
||
/// Colour outside `tex` (the surface's default pixel), the solid fill colour, or the effect
|
||
/// colour.
|
||
pub color: [f32; 4],
|
||
pub mask: Option<MaskUse<'a>>,
|
||
/// Adjustment kind (see `adjust` in compose.wgsl) and parameters; the coverage kind of an
|
||
/// effect paint.
|
||
pub adjust_kind: i32,
|
||
pub params: [[f32; 4]; 4],
|
||
pub extra: [f32; 4],
|
||
/// Extra shader flags (`F_KNOCKOUT`, …).
|
||
pub flags: u32,
|
||
/// 4096-entry LUT rows (Levels / Curves / Gradient map / gradient stops).
|
||
pub lut: Option<Vec<[f32; 4096]>>,
|
||
pub gradient: bool,
|
||
/// Effect map sampled by `FxPaint`.
|
||
pub map: Option<MapRef>,
|
||
/// Pattern painted by `FxPaint`.
|
||
pub pattern: Option<&'a Pattern>,
|
||
/// Only pixels inside this rect (document coordinates) are computed; the pass is skipped for
|
||
/// chunks it misses. Consumers of the slot must be clipped alike.
|
||
pub clip: Option<Rect>,
|
||
}
|
||
|
||
/// Pixels a pass samples: a surface of the document, or one derived from it on the CPU (a
|
||
/// combined pixel × vector mask, a shape's fill or stroke alone).
|
||
#[derive(Clone, Debug)]
|
||
pub enum SurfaceRef<'a> {
|
||
Doc(&'a Surface),
|
||
Derived(std::sync::Arc<Surface>),
|
||
}
|
||
|
||
impl SurfaceRef<'_> {
|
||
pub fn get(&self) -> &Surface {
|
||
match self {
|
||
SurfaceRef::Doc(s) => s,
|
||
SurfaceRef::Derived(s) => s,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct MaskUse<'a> {
|
||
pub layer: LayerId,
|
||
pub surface: SurfaceRef<'a>,
|
||
pub density: f32,
|
||
pub default: f32,
|
||
}
|
||
|
||
impl<'a> Pass<'a> {
|
||
pub(crate) fn new(kernel: Kernel, dst: Slot) -> Self {
|
||
Pass {
|
||
kernel,
|
||
dst,
|
||
a: None,
|
||
b: None,
|
||
c: None,
|
||
d: None,
|
||
mode: BlendMode::Normal,
|
||
opacity: 1.0,
|
||
fill: 1.0,
|
||
tex: None,
|
||
color: [0.0; 4],
|
||
mask: None,
|
||
adjust_kind: 0,
|
||
params: [[0.0; 4]; 4],
|
||
extra: [0.0; 4],
|
||
flags: 0,
|
||
lut: None,
|
||
gradient: false,
|
||
map: None,
|
||
pattern: None,
|
||
clip: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Why a document can't be composited on the GPU (the caller falls back to the CPU compositor).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Unsupported(pub String);
|
||
|
||
impl std::fmt::Display for Unsupported {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "GPU compositor: {}", self.0)
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for Unsupported {}
|
||
|
||
/// A layer whose effects the plan renders: its maps are built (or reused) before the chunks run.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct FxLayer<'a> {
|
||
pub layer: &'a Layer,
|
||
/// Region the maps cover (`compose::effect_maps`).
|
||
pub region: Rect,
|
||
/// Layer bounds (gradients and linked patterns are laid out in them).
|
||
pub bounds: Rect,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct Plan<'a> {
|
||
pub passes: Vec<Pass<'a>>,
|
||
pub slots: u32,
|
||
pub root: Slot,
|
||
/// Effect layers in dependency order (a group's inner effect layers come first).
|
||
pub fx: Vec<FxLayer<'a>>,
|
||
/// Complete root stack units, where only the accumulated backdrop remains live.
|
||
pub checkpoints: Vec<Checkpoint>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct Checkpoint {
|
||
/// First root layer included after occlusion pruning.
|
||
pub layer_start: usize,
|
||
/// Root layers below this boundary (including the complete clipping unit).
|
||
pub layer_end: usize,
|
||
/// Resume at this pass with `slot` restored.
|
||
pub pass_end: usize,
|
||
pub slot: Slot,
|
||
}
|
||
|
||
/// Document-level inputs of a plan.
|
||
#[derive(Clone, Copy)]
|
||
pub struct DocCtx<'a> {
|
||
pub canvas: Rect,
|
||
pub transfer: Transfer,
|
||
pub light: GlobalLight,
|
||
pub patterns: &'a [Pattern],
|
||
/// Colour mode (channel restrictions name its channels).
|
||
pub mode: photocraft_color::ColorMode,
|
||
/// Sample depth (adjustment results are rounded to it).
|
||
pub depth: photocraft_color::SampleType,
|
||
/// The document (effect geometry the CPU computes, e.g. gradient stroke frames).
|
||
pub doc: &'a Document,
|
||
}
|
||
|
||
impl<'a> DocCtx<'a> {
|
||
pub fn of(doc: &'a Document) -> Self {
|
||
DocCtx {
|
||
canvas: doc.bounds(),
|
||
transfer: Transfer::for_document(doc.mode, doc.depth),
|
||
light: doc.global_light,
|
||
patterns: &doc.patterns,
|
||
mode: doc.mode,
|
||
depth: doc.depth,
|
||
doc,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build the pass list for `doc`.
|
||
pub fn plan(doc: &Document) -> Result<Plan<'_>, Unsupported> {
|
||
if doc.mode == photocraft_color::ColorMode::Multichannel {
|
||
return Err(Unsupported("Multichannel inks (printed on the CPU)".into()));
|
||
}
|
||
let mut p = Planner::new(DocCtx::of(doc));
|
||
let root = p.clear();
|
||
// Start at the topmost layer that hides everything beneath it (an opaque fill layer): the
|
||
// layers below can't change the result, so they cost no passes or uploads.
|
||
let start = doc.layers.iter().rposition(|l| photocraft_compose::occludes_below(l, doc.mode)).unwrap_or(0);
|
||
let root = p.stack_inner(doc.layers.get(start..).unwrap_or(&doc.layers), root, Some(start))?;
|
||
Ok(p.finish(root))
|
||
}
|
||
|
||
struct Planner<'a> {
|
||
passes: Vec<Pass<'a>>,
|
||
free: Vec<Slot>,
|
||
refs: Vec<u32>,
|
||
cx: DocCtx<'a>,
|
||
fx: Vec<FxLayer<'a>>,
|
||
checkpoints: Vec<Checkpoint>,
|
||
}
|
||
|
||
/// Coverage source of an effect paint (`kind` in `fs_fxpaint`); flags refine it.
|
||
#[derive(Clone, Copy)]
|
||
enum Cov {
|
||
/// An effect map; `outside` beyond its region.
|
||
Map(MapRef, f32),
|
||
/// Full coverage.
|
||
One,
|
||
}
|
||
|
||
/// Shader flags for effect passes (keep in sync with compose.wgsl). Coverage `m` of a paint, with
|
||
/// `a` the layer's alpha and `inside` = `a > 0.5/255`:
|
||
/// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`;
|
||
/// stroke-out `inside ? (vector ? 0 : (1 − a) / (1 − layer alpha), 1 at an opaque layer) : m`.
|
||
pub const F_KNOCKOUT: u32 = 16;
|
||
/// Effect merge: A already holds the layer; only mix with the backdrop by the layer's opacity.
|
||
pub const F_NO_LAYER: u32 = 32;
|
||
/// Outside strokes along a filled shape's outline (`effects::outline_share`; the coverage slot
|
||
/// carries the layer's alpha in `.g`).
|
||
pub const F_OUTLINE: u32 = 131072;
|
||
/// Shape layer (outside strokes never show inside it).
|
||
pub const F_VECTOR: u32 = 64;
|
||
/// Merge onto an opaque clipping base, keeping its alpha.
|
||
pub const F_ATOP: u32 = 128;
|
||
pub const F_GATE: u32 = 256;
|
||
pub const F_REL: u32 = 512;
|
||
pub const F_STROKE_OUT: u32 = 1024;
|
||
/// First outside stroke (no accumulated share yet).
|
||
pub const F_FIRST: u32 = 2048;
|
||
/// `Lerp` per channel: weights in `p0` (channel restrictions), no opacity or mask.
|
||
pub const F_CHANNELS: u32 = 4096;
|
||
/// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`).
|
||
pub const F_LAB: u32 = 65536;
|
||
/// 32-bit float document: Add, Divide and Exposure retain values above 1; the non-separable
|
||
/// blend modes clip only below 0 (`psblend::HDR`).
|
||
pub const F_HDR: u32 = 262144;
|
||
/// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents).
|
||
pub const F_QUANT: u32 = 32768;
|
||
/// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups).
|
||
pub const F_ADD_DIFF: u32 = 16384;
|
||
/// `Lerp`: B mixed back towards A (the backdrop) by the layer's Blend If weight
|
||
/// (`compose::apply_blend_if`), from C ("This Layer", or B itself for an adjustment layer, `p4.y`)
|
||
/// and A ("Underlying Layer"). The ranges are packed in `p0..p3` (`blend_if_params`); `p4.x` marks
|
||
/// a one-channel document.
|
||
pub const F_BLEND_IF: u32 = 524288;
|
||
/// Blend / Atop / FxMerge of a type layer: coverage mixed at `psblend::TEXT_GAMMA`.
|
||
pub const F_TEXT_GAMMA: u32 = 8192;
|
||
/// `FxMerge`: B is the layer's shape (its content); the layer is its colour at fill opacity
|
||
/// (`p0.x`) inside the shape, times the shape's alpha: `FxInit` `INIT_INSIDE` then
|
||
/// `INIT_ALPHA` in the merge, for a layer with no interior effects between them.
|
||
pub const F_LAYER_SHAPE: u32 = 1_048_576;
|
||
|
||
/// A layer's Blend If ranges for `F_BLEND_IF`: Gray, then the colour channels, each as
|
||
/// [this layer, underlying layer]; every range as two floats, `black low × 256 + black high` and
|
||
/// `white low × 256 + white high` (exact in f32), so entry `e`, side `s` starts at float
|
||
/// `(e × 2 + s) × 2`. Entries past the fourth never apply (RGB has four, one-channel modes two).
|
||
fn blend_if_params(bi: &photocraft_doc::BlendIf) -> [[f32; 4]; 4] {
|
||
let mut f = [0.0f32; 16];
|
||
for e in 0..4 {
|
||
for (s, r) in bi.get(e).iter().enumerate() {
|
||
let i = (e * 2 + s) * 2;
|
||
f[i] = f32::from(r.black[0]) * 256.0 + f32::from(r.black[1]);
|
||
f[i + 1] = f32::from(r.white[0]) * 256.0 + f32::from(r.white[1]);
|
||
}
|
||
}
|
||
[[f[0], f[1], f[2], f[3]], [f[4], f[5], f[6], f[7]], [f[8], f[9], f[10], f[11]], [f[12], f[13], f[14], f[15]]]
|
||
}
|
||
|
||
/// `F_TEXT_GAMMA` for type layers while text gamma blending is on; the gamma goes in `p4.w`.
|
||
fn gamma_flag(layer: &Layer) -> u32 {
|
||
if photocraft_compose::text_gamma(layer) != 1.0 { F_TEXT_GAMMA } else { 0 }
|
||
}
|
||
|
||
/// `FxInit` kinds: A at `opacity` × alpha; an opaque copy of A; A's colour with alpha `opacity`
|
||
/// inside B's shape; A with alpha × B's alpha.
|
||
pub const INIT_SCALE: i32 = 0;
|
||
pub const INIT_OPAQUE: i32 = 1;
|
||
pub const INIT_INSIDE: i32 = 2;
|
||
pub const INIT_ALPHA: i32 = 3;
|
||
/// The vector stroke C over A relative to the shape B, at `opacity`.
|
||
pub const INIT_VSTROKE: i32 = 4;
|
||
/// A's colour with alpha `opacity` × A's alpha relative to B's.
|
||
pub const INIT_RELATIVE: i32 = 5;
|
||
/// Outside-stroke coverage seed: (0, A's alpha).
|
||
pub const INIT_COVER: i32 = 6;
|
||
/// A with its alpha joined with the effect shape (the map).
|
||
pub const INIT_OUTLINE: i32 = 7;
|
||
|
||
/// `MapRef::item` of the layer's effect shape (`compose::layer_shape`) instead of an effect map.
|
||
pub const SHAPE_MAP: usize = usize::MAX;
|
||
|
||
impl<'a> Planner<'a> {
|
||
fn new(cx: DocCtx<'a>) -> Self {
|
||
Planner { passes: Vec::new(), free: Vec::new(), refs: Vec::new(), cx, fx: Vec::new(), checkpoints: Vec::new() }
|
||
}
|
||
|
||
fn finish(self, root: Slot) -> Plan<'a> {
|
||
Plan { passes: self.passes, slots: self.refs.len() as u32, root, fx: self.fx, checkpoints: self.checkpoints }
|
||
}
|
||
|
||
fn alloc(&mut self) -> Slot {
|
||
if let Some(s) = self.free.pop() {
|
||
self.refs[s as usize] = 1;
|
||
return s;
|
||
}
|
||
self.refs.push(1);
|
||
(self.refs.len() - 1) as Slot
|
||
}
|
||
fn retain(&mut self, s: Slot) -> Slot {
|
||
self.refs[s as usize] += 1;
|
||
s
|
||
}
|
||
fn release(&mut self, s: Slot) {
|
||
let r = &mut self.refs[s as usize];
|
||
*r -= 1;
|
||
if *r == 0 {
|
||
self.free.push(s);
|
||
}
|
||
}
|
||
/// Emit `pass` into a fresh slot, releasing its inputs.
|
||
fn emit(&mut self, mut pass: Pass<'a>) -> Slot {
|
||
let dst = self.alloc();
|
||
pass.dst = dst;
|
||
if self.cx.mode == photocraft_color::ColorMode::Lab {
|
||
pass.flags |= F_LAB;
|
||
}
|
||
if self.cx.depth == photocraft_color::SampleType::F32 {
|
||
pass.flags |= F_HDR;
|
||
}
|
||
let (a, b, c, d) = (pass.a, pass.b, pass.c, pass.d);
|
||
self.passes.push(pass);
|
||
for s in [a, b, c, d].into_iter().flatten() {
|
||
self.release(s);
|
||
}
|
||
dst
|
||
}
|
||
fn clear(&mut self) -> Slot {
|
||
self.emit(Pass::new(Kernel::Clear, 0))
|
||
}
|
||
|
||
/// composite_stack: returns the new backdrop (consumes `backdrop`).
|
||
fn stack(&mut self, layers: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||
self.stack_inner(layers, backdrop, None)
|
||
}
|
||
|
||
fn stack_inner(&mut self, layers: &'a [Layer], mut backdrop: Slot, root_start: Option<usize>) -> Result<Slot, Unsupported> {
|
||
let mut i = 0;
|
||
while i < layers.len() {
|
||
let base = &layers[i];
|
||
let mut j = i + 1;
|
||
while j < layers.len() && layers[j].clipped && !base.clipped {
|
||
j += 1;
|
||
}
|
||
let clipped = &layers[i + 1..j];
|
||
if base.visible {
|
||
backdrop = self.layer(base, clipped, backdrop)?;
|
||
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, photocraft_compose::adjustment_quantum(self.cx.depth)) {
|
||
// compose: adjustment results are rounded to the document's depth.
|
||
let mut p = Pass::new(Kernel::Lerp, 0);
|
||
p.a = Some(backdrop);
|
||
p.flags = F_QUANT;
|
||
p.params[0] = [q, 0.0, 0.0, 0.0];
|
||
backdrop = self.emit(p);
|
||
}
|
||
}
|
||
i = j.max(i + 1);
|
||
if let Some(start) = root_start
|
||
&& self.refs.iter().enumerate().all(|(slot, &refs)| refs == u32::from(slot == backdrop as usize))
|
||
{
|
||
self.checkpoints.push(Checkpoint { layer_start: start, layer_end: start.saturating_add(i), pass_end: self.passes.len(), slot: backdrop });
|
||
}
|
||
}
|
||
Ok(backdrop)
|
||
}
|
||
|
||
fn mask_use(&self, layer: &'a Layer) -> Option<MaskUse<'a>> {
|
||
if let Some(c) = photocraft_compose::masks::combined_mask(layer, self.cx.canvas) {
|
||
let default = c.default_pixel().first().copied().unwrap_or(1.0);
|
||
return Some(MaskUse { layer: layer.id, surface: SurfaceRef::Derived(std::sync::Arc::new(c)), density: 1.0, default });
|
||
}
|
||
let m = layer.mask.as_ref()?;
|
||
if !m.enabled {
|
||
return None;
|
||
}
|
||
Some(MaskUse {
|
||
layer: layer.id,
|
||
surface: SurfaceRef::Doc(&m.surface),
|
||
density: m.density,
|
||
default: m.surface.default_pixel().first().copied().unwrap_or(1.0),
|
||
})
|
||
}
|
||
|
||
/// `compose::apply_blend_if`: `after` (the backdrop with `layer` drawn) mixed back towards
|
||
/// `before` (the backdrop without it) where the layer's Blend If ranges hide it. "This Layer"
|
||
/// is the layer's own content with its masks; an adjustment layer is judged by its result.
|
||
/// Consumes both slots.
|
||
fn blend_if(&mut self, layer: &'a Layer, before: Slot, after: Slot) -> Result<Slot, Unsupported> {
|
||
let own = match layer.content {
|
||
LayerContent::Adjustment(_) => None,
|
||
_ => Some(self.content(layer)?),
|
||
};
|
||
let mut p = Pass::new(Kernel::Lerp, 0);
|
||
p.a = Some(before);
|
||
p.b = Some(after);
|
||
p.c = own;
|
||
p.flags = F_BLEND_IF;
|
||
p.params = blend_if_params(&layer.blend_if);
|
||
let one_channel = self.cx.mode != photocraft_color::ColorMode::Rgb;
|
||
p.extra = [f32::from(u8::from(one_channel)), f32::from(u8::from(own.is_none())), 0.0, 0.0];
|
||
Ok(self.emit(p))
|
||
}
|
||
|
||
/// Channel restrictions and Blend If around `draw`, which composites `layer` onto `backdrop`
|
||
/// (`compose::composite_layer` / `composite_atop`: both are relative to the same backdrop).
|
||
fn restricted(&mut self, layer: &'a Layer, backdrop: Slot, draw: impl FnOnce(&mut Self, Slot) -> Result<Slot, Unsupported>) -> Result<Slot, Unsupported> {
|
||
let w = photocraft_compose::channel_weights(layer, self.cx.mode);
|
||
let blend_if = photocraft_compose::blend_if_active(layer, self.cx.mode);
|
||
if w.is_none() && !blend_if {
|
||
return draw(self, backdrop);
|
||
}
|
||
let before = self.retain(backdrop);
|
||
let mut after = draw(self, backdrop)?;
|
||
if let Some(w) = w {
|
||
let b = if blend_if { self.retain(before) } else { before };
|
||
after = self.restore_channels(b, after, w);
|
||
}
|
||
if blend_if {
|
||
after = self.blend_if(layer, before, after)?;
|
||
}
|
||
Ok(after)
|
||
}
|
||
|
||
/// Blending Options › Advanced Blending (knockout, clipped layers blended individually, blend
|
||
/// interior effects as group, transparency shapes off, masks hiding effects) has no GPU pass:
|
||
/// documents using it are composited on the CPU (`photocraft_compose::advanced`). Layers at
|
||
/// Photoshop's defaults pass this check for free.
|
||
fn check_advanced(&self, layer: &Layer, clipped: &[Layer]) -> Result<(), Unsupported> {
|
||
if photocraft_compose::advanced_active(layer, clipped) {
|
||
return Err(Unsupported(format!("Advanced Blending on `{}` (composited on the CPU)", layer.name)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// composite_layer, honouring the layer's channel restrictions.
|
||
fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||
self.check_advanced(layer, clipped)?;
|
||
self.restricted(layer, backdrop, |s, b| s.layer_any(layer, clipped, b))
|
||
}
|
||
|
||
/// `compose::restore_channels`: `after` with the channels a layer leaves out taken from
|
||
/// `before` (consumes both).
|
||
fn restore_channels(&mut self, before: Slot, after: Slot, w: [f32; 3]) -> Slot {
|
||
let mut p = Pass::new(Kernel::Lerp, 0);
|
||
p.a = Some(before);
|
||
p.b = Some(after);
|
||
p.flags = F_CHANNELS;
|
||
p.params[0] = [w[0], w[1], w[2], 1.0];
|
||
self.emit(p)
|
||
}
|
||
|
||
fn layer_any(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||
match layer.artboard() {
|
||
Some(ab) => self.artboard(layer, ab, clipped, backdrop),
|
||
None => self.layer_plain(layer, clipped, backdrop),
|
||
}
|
||
}
|
||
|
||
/// compose::composite_artboard: the board's background and the group composited over the
|
||
/// backdrop, kept only inside the board (contents and effects outside it are clipped away).
|
||
fn artboard(&mut self, layer: &'a Layer, ab: &photocraft_doc::Artboard, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||
let board = ab.rect.intersect(&self.cx.canvas);
|
||
if board.is_empty() {
|
||
return Ok(backdrop);
|
||
}
|
||
let before = self.retain(backdrop);
|
||
let mut sub = backdrop;
|
||
if let Some(bg) = ab.background.rgba() {
|
||
let mut c = Pass::new(Kernel::Content, 0);
|
||
c.color = bg;
|
||
let c = self.emit(c);
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.a = Some(sub);
|
||
p.b = Some(c);
|
||
sub = self.emit(p);
|
||
}
|
||
let sub = self.layer_plain(layer, clipped, sub)?;
|
||
let dst = if self.refs[before as usize] == 1 {
|
||
before
|
||
} else {
|
||
let n = self.alloc();
|
||
let mut p = Pass::new(Kernel::CopyFull, n);
|
||
p.a = Some(before);
|
||
self.passes.push(p);
|
||
self.release(before);
|
||
n
|
||
};
|
||
let mut p = Pass::new(Kernel::CopyRect, dst);
|
||
p.a = Some(sub);
|
||
p.clip = Some(board);
|
||
self.passes.push(p);
|
||
self.release(sub);
|
||
Ok(dst)
|
||
}
|
||
|
||
fn layer_plain(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result<Slot, Unsupported> {
|
||
let opacity = layer.opacity * layer.fill_opacity;
|
||
let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect();
|
||
|
||
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does), and
|
||
// so does one that is the base of a clipping group and holds an adjustment layer.
|
||
let clip_base_adjusts = !visible_clipped.is_empty()
|
||
&& matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_))));
|
||
if let LayerContent::Group(g) = &layer.content
|
||
&& layer.blend == BlendMode::PassThrough
|
||
&& layer.fill_opacity >= 1.0
|
||
&& !has_effects(layer)
|
||
&& !clip_base_adjusts
|
||
{
|
||
let before = self.retain(backdrop);
|
||
let mut after = self.stack(&g.children, backdrop)?;
|
||
if opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some() {
|
||
let mut p = Pass::new(Kernel::Lerp, 0);
|
||
p.a = Some(self.retain(before));
|
||
p.b = Some(after);
|
||
p.opacity = opacity;
|
||
p.mask = self.mask_use(layer);
|
||
after = self.emit(p);
|
||
}
|
||
if visible_clipped.is_empty() {
|
||
self.release(before);
|
||
return Ok(after);
|
||
}
|
||
// Layers clipped to a pass-through group (compose: their effect on the group's
|
||
// isolated rendering, each placed over the original backdrop, is added to the
|
||
// pass-through result, premultiplied).
|
||
let iso = self.content(layer)?;
|
||
let iso_keep = self.retain(iso);
|
||
let mut clipped_iso = iso;
|
||
for c in visible_clipped {
|
||
clipped_iso = self.atop(c, clipped_iso)?;
|
||
}
|
||
let over = |s: &mut Self, src: Slot, before: Slot| {
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.a = Some(before);
|
||
p.b = Some(src);
|
||
p.opacity = opacity;
|
||
s.emit(p)
|
||
};
|
||
let b2 = self.retain(before);
|
||
let without = over(self, iso_keep, b2);
|
||
let with = over(self, clipped_iso, before);
|
||
let mut p = Pass::new(Kernel::Lerp, 0);
|
||
p.a = Some(after);
|
||
p.b = Some(with);
|
||
p.c = Some(without);
|
||
p.flags = F_ADD_DIFF;
|
||
return Ok(self.emit(p));
|
||
}
|
||
|
||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||
let before = self.retain(backdrop);
|
||
let mut adjusted = self.adjust(adj, backdrop)?;
|
||
for c in visible_clipped {
|
||
adjusted = self.atop(c, adjusted)?;
|
||
}
|
||
let mut p = Pass::new(Kernel::AdjMix, 0);
|
||
p.a = Some(before);
|
||
p.b = Some(adjusted);
|
||
p.mode = layer.blend;
|
||
p.opacity = opacity;
|
||
p.mask = self.mask_use(layer);
|
||
return Ok(self.emit(p));
|
||
}
|
||
|
||
if has_effects(layer) {
|
||
return self.effects(layer, &visible_clipped, backdrop, false);
|
||
}
|
||
|
||
if let LayerContent::Shape(sh) = &layer.content
|
||
&& !visible_clipped.is_empty()
|
||
&& let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas, self.cx.depth)
|
||
{
|
||
// The vector stroke goes above the clipped layers: the fill is the clipping base,
|
||
// the stroke is laid over the clipped result, then the masks apply to both
|
||
// (compose::shape_parts).
|
||
let mut content = self.shape_part(layer, Role::Content, fill);
|
||
for c in visible_clipped {
|
||
content = self.atop(c, content)?;
|
||
}
|
||
let stroke = self.shape_part(layer, Role::Stroke, stroke);
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.a = Some(content);
|
||
p.b = Some(stroke);
|
||
let mut content = self.emit(p);
|
||
if let Some(m) = self.mask_use(layer) {
|
||
let mut p = Pass::new(Kernel::Mask, 0);
|
||
p.a = Some(content);
|
||
p.mask = Some(m);
|
||
content = self.emit(p);
|
||
}
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.a = Some(backdrop);
|
||
p.b = Some(content);
|
||
p.mode = layer.blend;
|
||
p.opacity = layer.opacity;
|
||
p.fill = layer.fill_opacity;
|
||
return Ok(self.emit(p));
|
||
}
|
||
|
||
// A layer that covers little of the canvas composites only over its bounds and is
|
||
// copied back into the backdrop (#125): a layout with hundreds of small text and shape
|
||
// layers otherwise shades every layer over every chunk.
|
||
let clip = self.local_clip(layer);
|
||
if clip.is_some_and(|c| c.is_empty()) {
|
||
// Nothing to draw (an empty layer, or a group of them).
|
||
for c in &visible_clipped {
|
||
self.check_advanced(c, &[])?;
|
||
}
|
||
return Ok(backdrop);
|
||
}
|
||
// A layer with nothing clipped to it whose content is one Content pass is blended
|
||
// straight from its pixels (`Kernel::LayerBlend`): one pass instead of two, and no
|
||
// scratch chunk written and read back.
|
||
let mut p = if visible_clipped.is_empty() && single_content_pass(layer) {
|
||
let mut p = self.content_pass(layer)?;
|
||
p.kernel = Kernel::LayerBlend;
|
||
p
|
||
} else {
|
||
let start = self.passes.len();
|
||
let mut content = self.content(layer)?;
|
||
for c in visible_clipped {
|
||
content = self.atop(c, content)?;
|
||
}
|
||
if let Some(clip) = clip {
|
||
// Everything that built the layer's content only matters inside its bounds.
|
||
for p in &mut self.passes[start..] {
|
||
if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) {
|
||
p.clip = Some(clip);
|
||
}
|
||
}
|
||
}
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.b = Some(content);
|
||
p
|
||
};
|
||
p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop });
|
||
p.mode = layer.blend;
|
||
p.opacity = layer.opacity;
|
||
p.fill = layer.fill_opacity;
|
||
p.flags |= gamma_flag(layer);
|
||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||
p.clip = clip;
|
||
let merged = self.emit(p);
|
||
match clip {
|
||
Some(clip) => Ok(self.write_back(backdrop, merged, clip)),
|
||
None => Ok(merged),
|
||
}
|
||
}
|
||
|
||
/// The rect a plain layer composites over: its composite bounds when they cover at most half
|
||
/// the canvas (beyond that, compositing everywhere costs less than the copy back).
|
||
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
|
||
let canvas = self.cx.canvas;
|
||
let b = bounds::composite_bounds(layer, canvas)?;
|
||
(b.width() as u64 * b.height() as u64 * 2 <= canvas.width() as u64 * canvas.height() as u64).then_some(b)
|
||
}
|
||
|
||
/// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop);
|
||
/// returns the new backdrop. Consumes both.
|
||
fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot {
|
||
// A clip over the whole canvas left no pixel of `merged` uncomputed: it is the backdrop.
|
||
if clip.contains_rect(&self.cx.canvas) {
|
||
self.release(backdrop);
|
||
return merged;
|
||
}
|
||
let dst = if self.refs[backdrop as usize] == 1 {
|
||
backdrop
|
||
} else {
|
||
let n = self.alloc();
|
||
let mut p = Pass::new(Kernel::CopyFull, n);
|
||
p.a = Some(backdrop);
|
||
self.passes.push(p);
|
||
self.release(backdrop);
|
||
n
|
||
};
|
||
let mut p = Pass::new(Kernel::CopyRect, dst);
|
||
p.a = Some(merged);
|
||
p.clip = Some(clip);
|
||
self.passes.push(p);
|
||
self.release(merged);
|
||
dst
|
||
}
|
||
|
||
/// A stroked shape's fill or vector stroke alone (`compose::shape_split`), unmasked.
|
||
fn shape_part(&mut self, layer: &'a Layer, role: Role, surface: Surface) -> Slot {
|
||
let mut p = Pass::new(Kernel::Content, 0);
|
||
p.color = photocraft_raster::to_rgba(&surface.format(), &surface.default_pixel());
|
||
if surface.tile_count() > 0 {
|
||
p.tex = Some(TexUse { layer: layer.id, role, surface: SurfaceRef::Derived(std::sync::Arc::new(surface)) });
|
||
}
|
||
self.emit(p)
|
||
}
|
||
|
||
/// render_content for non-adjustment layers.
|
||
fn content(&mut self, layer: &'a Layer) -> Result<Slot, Unsupported> {
|
||
match &layer.content {
|
||
LayerContent::Group(g) => {
|
||
let empty = self.clear();
|
||
let s = self.stack(&g.children, empty)?;
|
||
if layer.mask.is_some() || layer.vector_mask.is_some() {
|
||
let mut p = Pass::new(Kernel::Mask, 0);
|
||
p.a = Some(s);
|
||
p.mask = self.mask_use(layer);
|
||
return Ok(self.emit(p));
|
||
}
|
||
Ok(s)
|
||
}
|
||
// Adjustments are handled by the caller and never get here.
|
||
LayerContent::Adjustment(_) => Err(Unsupported("adjustment layer reached the content planner".into())),
|
||
LayerContent::Fill(f @ Fill::Pattern { name, scale, id, angle, link, phase }) if layer.fill_cache.as_ref().is_none_or(|c| c.fill != *f) => {
|
||
// compose::render_fill: the pattern tiled from the layer's frame (transparent
|
||
// when missing), then the layer's masks.
|
||
let empty = self.clear();
|
||
let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) else { return Ok(empty) };
|
||
let frame = photocraft_compose::fill_frame(layer, self.cx.canvas);
|
||
let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) };
|
||
let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle));
|
||
let canvas = self.cx.canvas;
|
||
let s = self.paint(empty, empty, Cov::One, &paint, BlendMode::Normal, 1.0, 0, canvas, canvas);
|
||
if layer.mask.is_some() || layer.vector_mask.is_some() {
|
||
let mut p = Pass::new(Kernel::Mask, 0);
|
||
p.a = Some(s);
|
||
p.mask = self.mask_use(layer);
|
||
return Ok(self.emit(p));
|
||
}
|
||
Ok(s)
|
||
}
|
||
_ => {
|
||
let p = self.content_pass(layer)?;
|
||
Ok(self.emit(p))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The Content pass of a raster / text / shape / smart / fill layer.
|
||
fn content_pass(&self, layer: &'a Layer) -> Result<Pass<'a>, Unsupported> {
|
||
let mut p = Pass::new(Kernel::Content, 0);
|
||
p.mask = self.mask_use(layer);
|
||
match &layer.content {
|
||
LayerContent::Fill(f) => match &layer.fill_cache {
|
||
Some(c) if c.fill == *f => self.surface_tex(&mut p, layer.id, &c.surface),
|
||
_ => self.fill(&mut p, f, photocraft_compose::fill_frame(layer, self.cx.canvas))?,
|
||
},
|
||
_ => {
|
||
if let Some(s) = layer.surface() {
|
||
self.surface_tex(&mut p, layer.id, s);
|
||
}
|
||
}
|
||
}
|
||
Ok(p)
|
||
}
|
||
|
||
fn surface_tex(&self, p: &mut Pass<'a>, id: LayerId, s: &'a Surface) {
|
||
let dp = s.default_pixel();
|
||
p.color = photocraft_raster::to_rgba(&s.format(), &dp);
|
||
if s.tile_count() > 0 {
|
||
p.tex = Some(TexUse { layer: id, role: Role::Content, surface: SurfaceRef::Doc(s) });
|
||
}
|
||
}
|
||
|
||
fn fill(&self, p: &mut Pass<'a>, f: &Fill, frame: photocraft_geom::Rect) -> Result<(), Unsupported> {
|
||
// The RGB shader cannot interpolate or dither native ink channels.
|
||
if self.cx.mode == photocraft_color::ColorMode::Cmyk && !matches!(f, Fill::Pattern { .. }) {
|
||
return Err(Unsupported("native CMYK fill (composited on the CPU)".into()));
|
||
}
|
||
match f {
|
||
Fill::Solid(c) => {
|
||
let rgb = c.to_rgb();
|
||
p.color = [rgb[0], rgb[1], rgb[2], c.alpha];
|
||
}
|
||
Fill::Gradient { stops, opacity_stops, angle, scale, style, reverse, offset, dither, .. } => {
|
||
check_gradient_stops(stops)?;
|
||
check_gradient_stops(opacity_stops)?;
|
||
p.gradient = true;
|
||
// compose::render_fill: whole-pixel end points (fill_layout).
|
||
let (angle, scale, offset) = photocraft_compose::fill_layout::gradient_layout(*style, *angle, *scale, *offset, frame);
|
||
p.params[0] = [angle, scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
|
||
let c = frame;
|
||
p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32];
|
||
// p2.xy: centre offset; p2.z: the depth's levels a dithered pixel is rounded to
|
||
// (0: float, compose::gradient_fill::render_quantized); p2.w: dither (the shared
|
||
// position hash, see the shader).
|
||
let quantum = photocraft_compose::adjustment_quantum(self.cx.depth).unwrap_or(0.0);
|
||
p.params[2] = [offset.0, offset.1, quantum, if *dither { 1.0 } else { 0.0 }];
|
||
let ramp = photocraft_compose::gradient_fill::Ramp::new(f);
|
||
let mut rows = vec![[0.0f32; 4096]; 4];
|
||
for k in 0..4096 {
|
||
let v = ramp.as_ref().map_or([0.0; 4], |r| r.sample(k as f32 / 4095.0));
|
||
for (ch, row) in rows.iter_mut().enumerate() {
|
||
row[k] = v[ch];
|
||
}
|
||
}
|
||
p.lut = Some(rows);
|
||
}
|
||
// Pattern fills fall back to the CPU compositor (see `check`).
|
||
Fill::Pattern { .. } => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// composite_atop: `layer` onto `base`, restricted to the base's alpha, honouring the
|
||
/// layer's channel restrictions.
|
||
fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result<Slot, Unsupported> {
|
||
self.check_advanced(layer, &[])?;
|
||
self.restricted(layer, base, |s, b| s.atop_any(layer, b))
|
||
}
|
||
|
||
fn atop_any(&mut self, layer: &'a Layer, base: Slot) -> Result<Slot, Unsupported> {
|
||
let opacity = layer.opacity * layer.fill_opacity;
|
||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||
let before = self.retain(base);
|
||
let adjusted = self.adjust(adj, base)?;
|
||
let mut p = Pass::new(Kernel::AdjMix, 0);
|
||
p.a = Some(before);
|
||
p.b = Some(adjusted);
|
||
p.mode = layer.blend;
|
||
p.opacity = opacity;
|
||
p.mask = self.mask_use(layer);
|
||
return Ok(self.emit(p));
|
||
}
|
||
if has_effects(layer) {
|
||
return self.effects(layer, &[], base, true);
|
||
}
|
||
let content = self.content(layer)?;
|
||
let mut p = Pass::new(Kernel::Atop, 0);
|
||
p.a = Some(base);
|
||
p.b = Some(content);
|
||
p.mode = layer.blend;
|
||
p.opacity = opacity;
|
||
p.flags = gamma_flag(layer);
|
||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||
Ok(self.emit(p))
|
||
}
|
||
|
||
/// adjust::apply_with on a slot (consumes it).
|
||
fn adjust(&mut self, adj: &Adjustment, src: Slot) -> Result<Slot, Unsupported> {
|
||
if !adjustment_on_gpu(adj, self.cx.mode) {
|
||
return Err(Unsupported(format!("{} on CMYK/Lab channels (evaluated on the CPU)", adj.label())));
|
||
}
|
||
if let Adjustment::GradientMap { stops, .. } = adj {
|
||
check_gradient_stops(stops)?;
|
||
}
|
||
let mut p = Pass::new(Kernel::Adjust, 0);
|
||
p.a = Some(src);
|
||
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, self.cx.depth);
|
||
p.adjust_kind = kind;
|
||
p.params = params;
|
||
p.lut = lut;
|
||
Ok(self.emit(p))
|
||
}
|
||
|
||
/// `composite_with_effects` (atop = false) or the clipped-layer variant of `composite_atop`
|
||
/// (atop = true: effects over the base treated as opaque, keeping the base's alpha).
|
||
/// Consumes `backdrop`.
|
||
fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result<Slot, Unsupported> {
|
||
let canvas = self.cx.canvas;
|
||
let region = bounds::effect_region(layer, canvas);
|
||
let sb = photocraft_compose::paint_bounds(layer).unwrap_or_else(|| bounds::layer_bounds(layer, canvas));
|
||
let clip = if bounds::transparent_outside(layer) { region } else { canvas };
|
||
// A stroked shape's vector stroke goes above its clipped layers and interior effects
|
||
// (compose::split_parts): the fill and the stroke unmasked, the masks applied after.
|
||
let split = match &layer.content {
|
||
LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas, self.cx.depth),
|
||
_ => None,
|
||
};
|
||
let (mut content, vstroke) = match split {
|
||
Some((fill, stroke)) => {
|
||
let f = self.shape_part(layer, Role::Content, fill);
|
||
let s = self.shape_part(layer, Role::Stroke, stroke);
|
||
(f, Some(s))
|
||
}
|
||
None => (self.content(layer)?, None),
|
||
};
|
||
for p in self.passes.iter_mut().rev().take(if vstroke.is_some() { 2 } else { 1 }) {
|
||
if !matches!(layer.content, LayerContent::Group(_)) {
|
||
p.clip = Some(clip);
|
||
}
|
||
}
|
||
for c in clipped {
|
||
content = self.atop(c, content)?;
|
||
}
|
||
let fx = self.fx.len();
|
||
self.fx.push(FxLayer { layer, region, bounds: sb });
|
||
let outline = photocraft_compose::effect_outline(layer).is_some();
|
||
let relative = outline || vstroke.is_some();
|
||
// `content` becomes the effect shape (B of every effect pass); `lay_src` the layer's colour.
|
||
let (lay_src, vstroke) = match vstroke {
|
||
Some(s) => {
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.a = Some(self.retain(content));
|
||
p.b = Some(self.retain(s));
|
||
p.clip = Some(clip);
|
||
let union = self.emit(p);
|
||
let m = self.mask_use(layer);
|
||
let masked = |me: &mut Self, slot: Slot| match &m {
|
||
Some(m) => {
|
||
let mut p = Pass::new(Kernel::Mask, 0);
|
||
p.a = Some(slot);
|
||
p.mask = Some(m.clone());
|
||
p.clip = Some(clip);
|
||
me.emit(p)
|
||
}
|
||
None => slot,
|
||
};
|
||
let fill = masked(self, content);
|
||
content = masked(self, union);
|
||
(fill, Some(masked(self, s)))
|
||
}
|
||
None => (self.retain(content), None),
|
||
};
|
||
if outline {
|
||
let mut p = Pass::new(Kernel::FxInit, 0);
|
||
p.a = Some(content);
|
||
p.adjust_kind = INIT_OUTLINE;
|
||
p.map = Some(MapRef { fx, item: SHAPE_MAP, map: 0 });
|
||
p.clip = Some(clip);
|
||
content = self.emit(p);
|
||
}
|
||
|
||
let items: Vec<&'a Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
|
||
for e in &items {
|
||
let gradient = match e {
|
||
Effect::GradientOverlay { gradient, .. } => Some(gradient),
|
||
Effect::Stroke(s) => match &s.paint {
|
||
FxPaint::Gradient(g) => Some(g),
|
||
_ => None,
|
||
},
|
||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => match &g.paint {
|
||
FxPaint::Gradient(g) => Some(g),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
};
|
||
if let Some(g) = gradient {
|
||
check_gradient_stops(&g.stops)?;
|
||
check_gradient_stops(&g.opacity_stops)?;
|
||
}
|
||
}
|
||
// Linked patterns tile from the effects reference point (else the layer's top-left).
|
||
let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0)));
|
||
let vector_shape = matches!(layer.content, LayerContent::Shape(_)) && !outline;
|
||
let map = |item: usize, map: usize| MapRef { fx, item, map };
|
||
let rev: Vec<(usize, &'a Effect)> = items.iter().copied().enumerate().rev().collect();
|
||
let init = |s: &mut Self, src: Slot, b: Option<Slot>, kind: i32, opacity: f32| {
|
||
let mut p = Pass::new(Kernel::FxInit, 0);
|
||
p.a = Some(src);
|
||
p.b = b;
|
||
p.adjust_kind = kind;
|
||
p.opacity = opacity;
|
||
p.clip = Some(clip);
|
||
s.emit(p)
|
||
};
|
||
|
||
// Exterior effects over the backdrop (an opaque copy of it for clipped layers).
|
||
let mut w = if atop {
|
||
let b = self.retain(backdrop);
|
||
init(self, b, None, INIT_OPAQUE, 1.0)
|
||
} else {
|
||
self.retain(backdrop)
|
||
};
|
||
for &(i, e) in &rev {
|
||
if let Effect::DropShadow(s) = e {
|
||
// The layer hides the shadow beneath it where its fill is see-through, and wholly
|
||
// in a mode that blends with it (as the CPU renderer: `shadow_see_through`).
|
||
let flags = if s.knocks_out { F_KNOCKOUT } else { 0 };
|
||
let see_through = photocraft_compose::effects::shadow_see_through(layer);
|
||
w = self.paint_k(
|
||
w,
|
||
content,
|
||
Cov::Map(map(i, 0), 0.0),
|
||
&Paint::Color(s.color.to_rgb()),
|
||
s.common.blend,
|
||
s.common.opacity,
|
||
flags,
|
||
clip,
|
||
sb,
|
||
see_through,
|
||
);
|
||
}
|
||
}
|
||
for &(i, e) in &rev {
|
||
if let Effect::OuterGlow(g) = e {
|
||
let paint = self.glow_paint(g, anchor);
|
||
// Match CPU exterior knockout: a see-through fill must not reveal the
|
||
// glow through its own shape. The shader applies m × (1 − alpha × k).
|
||
let see_through = 1.0 - layer.fill_opacity.clamp(0.0, 1.0);
|
||
w = self.paint_k(w, content, Cov::Map(map(i, 0), 0.0), &paint, g.common.blend, g.common.opacity, F_KNOCKOUT, clip, sb, see_through);
|
||
}
|
||
}
|
||
|
||
// Only exterior effects (drop shadows, outer glows): nothing is painted between the
|
||
// layer's colour inside its shape and the shape's alpha, so the merge derives both from
|
||
// the shape (`F_LAYER_SHAPE`) instead of two passes building the layer.
|
||
let premerge = photocraft_compose::effects::premerges_layer(layer);
|
||
if !relative && !premerge && items.iter().all(|e| matches!(e, Effect::DropShadow(_) | Effect::OuterGlow(_))) {
|
||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||
p.a = Some(w);
|
||
p.b = Some(lay_src);
|
||
p.c = Some(self.retain(backdrop));
|
||
p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||
p.opacity = layer.opacity;
|
||
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer) | F_LAYER_SHAPE;
|
||
p.params[0][0] = layer.fill_opacity;
|
||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||
p.clip = Some(clip);
|
||
let merged = self.emit(p);
|
||
self.release(content);
|
||
return Ok(self.write_back(backdrop, merged, clip));
|
||
}
|
||
// The layer: its colour at fill opacity inside its shape; interior effects are painted
|
||
// relative to the shape (coverage within it), then the shape's alpha applies.
|
||
let mut l = if relative {
|
||
let c = self.retain(content);
|
||
init(self, lay_src, Some(c), INIT_RELATIVE, layer.fill_opacity)
|
||
} else {
|
||
init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity)
|
||
};
|
||
// Blend Interior Effects as Group off: the layer merges in its mode first and the
|
||
// interior effects paint over the result (`effects::premerges_layer`).
|
||
if premerge {
|
||
let mut p = Pass::new(Kernel::Blend, 0);
|
||
p.a = Some(self.retain(w));
|
||
p.b = Some(l);
|
||
p.mode = layer.blend;
|
||
p.flags = gamma_flag(layer);
|
||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||
p.clip = Some(clip);
|
||
l = self.emit(p);
|
||
}
|
||
for &(_, e) in &rev {
|
||
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e
|
||
&& let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable())
|
||
{
|
||
let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle));
|
||
l = self.paint(l, content, Cov::One, &paint, common.blend, common.opacity, F_GATE, clip, sb);
|
||
}
|
||
}
|
||
for &(_, e) in &rev {
|
||
if let Effect::GradientOverlay { common, gradient, .. } = e {
|
||
l = self.paint(l, content, Cov::One, &Paint::Gradient(gradient), common.blend, common.opacity, F_GATE, clip, sb);
|
||
}
|
||
}
|
||
for &(_, e) in &rev {
|
||
if let Effect::ColorOverlay { common, color } = e {
|
||
l = self.paint(l, content, Cov::One, &Paint::Color(color.to_rgb()), common.blend, common.opacity, F_GATE, clip, sb);
|
||
}
|
||
}
|
||
for &(i, e) in &rev {
|
||
if let Effect::Satin(s) = e {
|
||
l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, F_REL, clip, sb);
|
||
}
|
||
}
|
||
for &(i, e) in &rev {
|
||
if let Effect::InnerGlow(g) = e {
|
||
let paint = self.glow_paint(g, anchor);
|
||
l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &paint, g.common.blend, g.common.opacity, F_REL, clip, sb);
|
||
}
|
||
}
|
||
for &(i, e) in &rev {
|
||
if let Effect::InnerShadow(s) = e {
|
||
l = self.paint(l, content, Cov::Map(map(i, 0), 0.0), &Paint::Color(s.color.to_rgb()), s.common.blend, s.common.opacity, F_REL, clip, sb);
|
||
}
|
||
}
|
||
if let Some(s) = vstroke {
|
||
let mut p = Pass::new(Kernel::FxInit, 0);
|
||
p.a = Some(l);
|
||
p.b = Some(self.retain(content));
|
||
p.c = Some(s);
|
||
p.adjust_kind = INIT_VSTROKE;
|
||
p.opacity = layer.fill_opacity;
|
||
p.clip = Some(clip);
|
||
l = self.emit(p);
|
||
}
|
||
// Inside stroke parts are painted over the layer (bottom instance first).
|
||
for &(i, e) in &rev {
|
||
if let Effect::Stroke(st) = e {
|
||
let (in_w, _) = stroke_widths(st);
|
||
if in_w > 0.0 {
|
||
let paint = self.fx_paint(&st.paint, anchor);
|
||
l = self.paint(
|
||
l,
|
||
content,
|
||
Cov::Map(map(i, 1), (in_w + 0.5).clamp(0.0, 1.0)),
|
||
&paint,
|
||
st.common.blend,
|
||
st.common.opacity,
|
||
F_GATE,
|
||
clip,
|
||
photocraft_compose::stroke_frame(self.cx.doc, layer, st).unwrap_or(sb),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint;
|
||
for &(i, e) in &rev {
|
||
if let Effect::BevelEmboss(b) = e
|
||
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Inner
|
||
{
|
||
l = self.paint(
|
||
l,
|
||
content,
|
||
Cov::Map(map(i, 0), 0.0),
|
||
&Paint::Color(b.highlight_color.to_rgb()),
|
||
b.highlight.blend,
|
||
b.highlight.opacity,
|
||
F_REL,
|
||
clip,
|
||
sb,
|
||
);
|
||
l = self.paint(l, content, Cov::Map(map(i, 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, F_REL, clip, sb);
|
||
}
|
||
}
|
||
// The shape's own alpha.
|
||
let c = self.retain(content);
|
||
l = init(self, l, Some(c), INIT_ALPHA, 1.0);
|
||
// Outside stroke parts lie beneath the layer and blend onto the exterior result with their
|
||
// own mode; a higher stroke covers the ones below it (each takes its coverage × opacity
|
||
// not yet taken above it, blended over the result as it was before the strokes).
|
||
let outs: Vec<(usize, &photocraft_doc::StrokeFx)> = items
|
||
.iter()
|
||
.copied()
|
||
.enumerate()
|
||
.filter_map(|(i, e)| if let Effect::Stroke(st) = e { Some((i, st)) } else { None })
|
||
.filter(|(_, st)| stroke_widths(st).1 > 0.0)
|
||
.collect();
|
||
if !outs.is_empty() {
|
||
let (mut acc, mut cover): (Option<Slot>, Option<Slot>) = (None, None);
|
||
if outline || !vector_shape {
|
||
// The layer's alpha rides along the coverage: `effects::outline_share`, and for
|
||
// pixel layers the stroke's share beneath a partly covering layer (fill < 100 %).
|
||
let ll = self.retain(l);
|
||
cover = Some(init(self, ll, None, INIT_COVER, 1.0));
|
||
}
|
||
for (n, &(i, st)) in outs.iter().enumerate() {
|
||
let paint = self.fx_paint(&st.paint, anchor);
|
||
let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if outline { F_OUTLINE } else { 0 } | if n == 0 { F_FIRST } else { 0 };
|
||
// Share × blended colour, then coverage (both read the coverage so far).
|
||
let mut p = self.fx_pass_kernel(
|
||
Kernel::FxStroke,
|
||
w,
|
||
content,
|
||
&paint,
|
||
st.common.blend,
|
||
st.common.opacity,
|
||
flags,
|
||
clip,
|
||
photocraft_compose::stroke_frame(self.cx.doc, layer, st).unwrap_or(sb),
|
||
);
|
||
if matches!(paint, Paint::None) {
|
||
// A missing pattern paints nothing: the stroke's share keeps the result as is.
|
||
p.params[2][2] = 3.0;
|
||
}
|
||
p.adjust_kind = 0;
|
||
p.map = Some(map(i, 0));
|
||
p.c = cover.map(|c| self.retain(c));
|
||
p.d = acc;
|
||
let a = self.emit(p);
|
||
let mut p = self.fx_pass_kernel(Kernel::FxStroke, w, content, &Paint::Color([0.0; 3]), BlendMode::Normal, st.common.opacity, flags, clip, sb);
|
||
p.adjust_kind = 1;
|
||
p.map = Some(map(i, 0));
|
||
p.c = cover;
|
||
cover = Some(self.emit(p));
|
||
acc = Some(a);
|
||
}
|
||
let mut p = Pass::new(Kernel::FxStrokeEnd, 0);
|
||
p.a = Some(w);
|
||
p.b = acc;
|
||
p.c = cover;
|
||
p.clip = Some(clip);
|
||
w = self.emit(p);
|
||
}
|
||
for &(i, e) in &rev {
|
||
if let Effect::BevelEmboss(b) = e
|
||
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer
|
||
{
|
||
let k = 0;
|
||
w = self.paint(
|
||
w,
|
||
content,
|
||
Cov::Map(map(i, k), 0.0),
|
||
&Paint::Color(b.highlight_color.to_rgb()),
|
||
b.highlight.blend,
|
||
b.highlight.opacity,
|
||
0,
|
||
clip,
|
||
sb,
|
||
);
|
||
w = self.paint(w, content, Cov::Map(map(i, k + 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, 0, clip, sb);
|
||
}
|
||
}
|
||
|
||
let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||
let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev
|
||
.iter()
|
||
.filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None })
|
||
.filter(|(_, b)| bevel_paint(b) == photocraft_compose::effects::BevelPaint::Both)
|
||
.collect();
|
||
let merged = if late.is_empty() {
|
||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||
p.a = Some(w);
|
||
p.b = Some(l);
|
||
p.c = Some(self.retain(backdrop));
|
||
p.mode = mode;
|
||
p.opacity = layer.opacity;
|
||
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
|
||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||
p.clip = Some(clip);
|
||
self.emit(p)
|
||
} else {
|
||
// Emboss styles shade the composited layer (`composite_with_effects`): merge at full
|
||
// opacity, paint their maps (inside and outside halves), then mix.
|
||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||
p.a = Some(w);
|
||
p.b = Some(l);
|
||
p.c = Some(self.retain(w));
|
||
p.mode = mode;
|
||
p.opacity = 1.0;
|
||
p.flags = gamma_flag(layer);
|
||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||
p.clip = Some(clip);
|
||
let mut m = self.emit(p);
|
||
for (i, b) in late {
|
||
for (k, color, fxc) in [(0, &b.highlight_color, &b.highlight), (1, &b.shadow_color, &b.shadow)] {
|
||
m = self.paint(m, content, Cov::Map(map(i, k), 0.0), &Paint::Color(color.to_rgb()), fxc.blend, fxc.opacity, 0, clip, sb);
|
||
}
|
||
}
|
||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||
p.b = Some(self.retain(m));
|
||
p.a = Some(m);
|
||
p.c = Some(self.retain(backdrop));
|
||
p.mode = mode;
|
||
p.opacity = layer.opacity;
|
||
p.flags = if atop { F_ATOP } else { 0 } | F_NO_LAYER;
|
||
p.clip = Some(clip);
|
||
self.emit(p)
|
||
};
|
||
self.release(content);
|
||
|
||
// Write the clipped result back into the backdrop (in place when nothing else holds it).
|
||
Ok(self.write_back(backdrop, merged, clip))
|
||
}
|
||
|
||
fn fx_paint(&self, p: &'a FxPaint, anchor: (f64, f64)) -> Paint<'a> {
|
||
match p {
|
||
FxPaint::Color(c) => Paint::Color(c.to_rgb()),
|
||
FxPaint::Gradient(g) => Paint::Gradient(g),
|
||
FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) {
|
||
Some(pat) => Paint::Pattern(pat, placement(anchor, true, (0.0, 0.0), *scale, 0.0)),
|
||
None => Paint::None,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn glow_paint(&self, g: &'a Glow, anchor: (f64, f64)) -> Paint<'a> {
|
||
match &g.paint {
|
||
FxPaint::Gradient(gradient) => Paint::GlowGradient(gradient, photocraft_compose::effects::glow_gradient_gain(g.range)),
|
||
p => self.fx_paint(p, anchor),
|
||
}
|
||
}
|
||
|
||
/// An effect pass reading `dst` (A, retained) and the layer `content` (B, retained) with its
|
||
/// paint set up.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn fx_pass(&mut self, dst: Slot, content: Slot, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Pass<'a> {
|
||
self.fx_pass_kernel(Kernel::FxPaint, dst, content, paint, blend, opacity, flags, clip, sb)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn fx_pass_kernel(
|
||
&mut self,
|
||
kernel: Kernel,
|
||
dst: Slot,
|
||
content: Slot,
|
||
paint: &Paint<'a>,
|
||
blend: BlendMode,
|
||
opacity: f32,
|
||
flags: u32,
|
||
clip: Rect,
|
||
sb: Rect,
|
||
) -> Pass<'a> {
|
||
let mut p = Pass::new(kernel, 0);
|
||
p.a = Some(self.retain(dst));
|
||
p.b = Some(self.retain(content));
|
||
p.mode = blend;
|
||
p.opacity = opacity;
|
||
p.flags = flags;
|
||
p.clip = Some(clip);
|
||
match paint {
|
||
Paint::None => {}
|
||
Paint::Color(c) => p.color = [c[0], c[1], c[2], 1.0],
|
||
Paint::Gradient(g) => {
|
||
// effects::paint_fx: whole-pixel end points (fill_layout::gradient_layout).
|
||
let (angle, scale, offset) = photocraft_compose::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, sb);
|
||
p.params[0] = [angle, scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
|
||
p.params[1] = [sb.x0 as f32, sb.y0 as f32, sb.width() as f32, sb.height() as f32];
|
||
p.params[2][0] = offset.0;
|
||
p.params[2][1] = offset.1;
|
||
p.params[2][2] = 1.0;
|
||
p.lut = Some(gradient_rows(g));
|
||
}
|
||
Paint::GlowGradient(g, gain) => {
|
||
// effects::paint_glow: the gradient at 1 - strength, opaque from 1 / gain.
|
||
p.params[0][0] = *gain;
|
||
p.params[2][2] = 4.0;
|
||
p.lut = Some(gradient_rows(g));
|
||
}
|
||
Paint::Pattern(pat, pl) => {
|
||
p.params[2][2] = 2.0;
|
||
p.params[3] = [pl.origin.0 as f32, pl.origin.1 as f32, pl.cs.0 as f32, pl.cs.1 as f32];
|
||
p.extra = [pl.inv_scale as f32, pat.width as f32, pat.height as f32, 0.0];
|
||
p.pattern = Some(pat);
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
/// One effect paint into `dst` (consumed); reads the layer's alpha from `content` (kept).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn paint(&mut self, dst: Slot, content: Slot, cov: Cov, paint: &Paint<'a>, blend: BlendMode, opacity: f32, flags: u32, clip: Rect, sb: Rect) -> Slot {
|
||
self.paint_k(dst, content, cov, paint, blend, opacity, flags, clip, sb, 1.0)
|
||
}
|
||
|
||
/// [`Self::paint`] with the knockout strength (`F_KNOCKOUT`: coverage × (1 − alpha × k)).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn paint_k(
|
||
&mut self,
|
||
dst: Slot,
|
||
content: Slot,
|
||
cov: Cov,
|
||
paint: &Paint<'a>,
|
||
blend: BlendMode,
|
||
opacity: f32,
|
||
flags: u32,
|
||
clip: Rect,
|
||
sb: Rect,
|
||
knockout: f32,
|
||
) -> Slot {
|
||
if matches!(paint, Paint::None) {
|
||
// Missing pattern: compose paints nothing.
|
||
return dst;
|
||
}
|
||
let mut p = self.fx_pass(dst, content, paint, blend, opacity, flags, clip, sb);
|
||
self.release(dst);
|
||
match cov {
|
||
Cov::Map(m, outside) => {
|
||
p.adjust_kind = 0;
|
||
p.map = Some(m);
|
||
p.params[2][3] = outside;
|
||
}
|
||
Cov::One => p.adjust_kind = 1,
|
||
}
|
||
p.extra[3] = knockout;
|
||
self.emit(p)
|
||
}
|
||
}
|
||
|
||
/// Paint source of an effect.
|
||
enum Paint<'a> {
|
||
None,
|
||
Color([f32; 3]),
|
||
Gradient(&'a Gradient),
|
||
/// A glow's gradient with its opacity gain (`effects::paint_glow`).
|
||
GlowGradient(&'a Gradient, f32),
|
||
Pattern(&'a Pattern, Placement),
|
||
}
|
||
|
||
/// `compose::pattern::Placement` (origin, inverse rotation, inverse scale).
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Placement {
|
||
origin: (f64, f64),
|
||
cs: (f64, f64),
|
||
inv_scale: f64,
|
||
}
|
||
|
||
/// `Placement::anchored`: linked patterns tile from `anchor` (the effects reference point).
|
||
fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
|
||
let base = if link { anchor } else { (0.0, 0.0) };
|
||
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
|
||
let s = f64::from(scale);
|
||
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
|
||
let a = f64::from(angle).to_radians();
|
||
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
|
||
}
|
||
|
||
/// (inside width, outside width) of a stroke.
|
||
pub fn stroke_widths(st: &photocraft_doc::StrokeFx) -> (f32, f32) {
|
||
match st.position {
|
||
StrokePosition::Outside => (0.0, st.size),
|
||
StrokePosition::Inside => (st.size, 0.0),
|
||
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
|
||
}
|
||
}
|
||
|
||
fn style_index(s: photocraft_doc::GradientStyle) -> f32 {
|
||
match s {
|
||
photocraft_doc::GradientStyle::Linear => 0.0,
|
||
photocraft_doc::GradientStyle::Radial => 1.0,
|
||
photocraft_doc::GradientStyle::Angle => 2.0,
|
||
photocraft_doc::GradientStyle::Reflected => 3.0,
|
||
photocraft_doc::GradientStyle::Diamond => 4.0,
|
||
}
|
||
}
|
||
|
||
fn sample_stops4(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
|
||
match stops {
|
||
[] => [0.0; 4],
|
||
[only] => only.1,
|
||
_ => {
|
||
if t <= stops[0].0 {
|
||
return stops[0].1;
|
||
}
|
||
for w in stops.windows(2) {
|
||
let (a, b) = (&w[0], &w[1]);
|
||
if t <= b.0 {
|
||
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||
return std::array::from_fn(|i| a.1[i] + (b.1[i] - a.1[i]) * k);
|
||
}
|
||
}
|
||
stops[stops.len() - 1].1
|
||
}
|
||
}
|
||
}
|
||
|
||
fn lut_rows(f: impl Fn(usize, f32) -> f32, rows: usize) -> Vec<[f32; 4096]> {
|
||
(0..rows)
|
||
.map(|r| {
|
||
let mut row = [0.0f32; 4096];
|
||
for (k, v) in row.iter_mut().enumerate() {
|
||
*v = f(r, k as f32 / 4095.0);
|
||
}
|
||
row
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
type Program = (i32, [[f32; 4]; 4], Option<Vec<[f32; 4096]>>);
|
||
|
||
/// A CPU LUT (4096 entries) as one texture row.
|
||
fn to_row(t: &[f32]) -> [f32; 4096] {
|
||
let mut row = [0.0f32; 4096];
|
||
for (o, v) in row.iter_mut().zip(t) {
|
||
*o = *v;
|
||
}
|
||
row
|
||
}
|
||
|
||
/// Whether the adjustment kernel can evaluate `adj` in a document of colour `mode`: Levels and
|
||
/// Curves on CMYK ink or Lab channels convert through ICC profiles per pixel, and so do the
|
||
/// adjustments a Lab document evaluates on L, a and b (`photocraft_compose::adjust::lab_channel_adjustment`)
|
||
/// and a CMYK document on its inks (`cmyk_channel_adjustment`), which only the CPU does.
|
||
pub fn adjustment_on_gpu(adj: &Adjustment, mode: photocraft_color::ColorMode) -> bool {
|
||
use photocraft_compose::adjust::{cmyk_channel_adjustment, lab_channel_adjustment};
|
||
let tone_in_space =
|
||
matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. });
|
||
let own_channels = match mode {
|
||
photocraft_color::ColorMode::Lab => lab_channel_adjustment(adj),
|
||
photocraft_color::ColorMode::Cmyk => cmyk_channel_adjustment(adj),
|
||
_ => false,
|
||
};
|
||
!tone_in_space && !own_channels
|
||
}
|
||
|
||
/// A tone transfer as the shader's `t_decode`/`t_encode` exponent (0: the sRGB curve).
|
||
fn transfer_exponent(t: Transfer) -> f32 {
|
||
match t {
|
||
Transfer::Srgb => 0.0,
|
||
Transfer::Gamma(g) => g,
|
||
}
|
||
}
|
||
|
||
/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
|
||
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType) -> Program {
|
||
let mut p = [[0.0f32; 4]; 4];
|
||
match adj {
|
||
Adjustment::Invert => (1, p, None),
|
||
Adjustment::Threshold { level } => {
|
||
// The level, the depth's split (compose::adjust::threshold_white) and the level in 16 bits.
|
||
let t = (level * 255.0).round();
|
||
p[0] = match depth {
|
||
photocraft_color::SampleType::U8 => [t, 1.0, 0.0, 0.0],
|
||
photocraft_color::SampleType::U16 => [t, 2.0, (t * 32768.0 / 255.0).round(), 0.0],
|
||
photocraft_color::SampleType::F32 => [t, 0.0, 0.0, 0.0],
|
||
};
|
||
(2, p, None)
|
||
}
|
||
Adjustment::Posterize { levels } => {
|
||
// The levels, and the 16-bit rule (compose::adjust::posterize_in).
|
||
p[0][0] = (*levels).clamp(2, 255) as f32;
|
||
p[0][1] = if depth == photocraft_color::SampleType::U16 { 1.0 } else { 0.0 };
|
||
(3, p, None)
|
||
}
|
||
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
|
||
let c = contrast.clamp(-100.0, 99.0);
|
||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||
// Brightness before contrast when it is raised, after it when lowered (compose::adjust).
|
||
let (pre, post) = if c >= 0.0 { (brightness / 255.0, 0.0) } else { (0.0, brightness / 255.0) };
|
||
// 32-bit documents pivot at 127 of 255 and don't clip (the shader checks F_HDR).
|
||
let pivot = if depth == photocraft_color::SampleType::F32 { 127.0 / 255.0 } else { photocraft_compose::adjust::LEGACY_PIVOT };
|
||
p[0] = [pre, k, pivot, post];
|
||
(4, p, None)
|
||
}
|
||
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
|
||
// Modern B/C: the CPU's lookup table (compose::adjust::modern_lut) on every channel,
|
||
// through the Levels / Curves kernel.
|
||
let row = to_row(&adjust::modern_lut(*brightness, *contrast));
|
||
(7, p, Some(vec![row; 3]))
|
||
}
|
||
Adjustment::Exposure { exposure, offset, gamma } => {
|
||
p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), transfer_exponent(transfer.for_exposure())];
|
||
(6, p, None)
|
||
}
|
||
Adjustment::Levels { master, per_channel, space: ToneSpace::Rgb, .. } if depth == photocraft_color::SampleType::F32 => {
|
||
// Twenty coefficients exceed the sixteen uniform parameters. Store four records
|
||
// in LUT row 0, read texel-exact by the analytic float Levels kernel.
|
||
let mut row = [0.0f32; 4096];
|
||
for (i, ch) in std::iter::once(master).chain(per_channel).enumerate() {
|
||
let coefficients = [ch.in_black, ch.in_white, ch.gamma.max(0.01), ch.out_black, ch.out_white];
|
||
row[i * 5..i * 5 + 5].copy_from_slice(&coefficients);
|
||
}
|
||
(17, p, Some(vec![row]))
|
||
}
|
||
// RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
|
||
Adjustment::Levels { .. } | Adjustment::Curves { .. } => {
|
||
for (v, identity) in p[0].iter_mut().zip(adjust::float_tone_identity(adj, Some(depth))) {
|
||
*v = if identity { 1.0 } else { 0.0 };
|
||
}
|
||
(7, p, Some(adjust::tone_luts_depth(adj, Some(depth)).iter().take(3).map(|t| to_row(t)).collect()))
|
||
}
|
||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||
p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }];
|
||
if !*colorize && ranges.iter().any(|r| !r.is_neutral()) {
|
||
// Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU.
|
||
p[1][0] = 1.0;
|
||
(8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))
|
||
} else {
|
||
(8, p, None)
|
||
}
|
||
}
|
||
Adjustment::Vibrance { vibrance, saturation } => {
|
||
// compose::adjust::vibrance_px; 32-bit samples aren't clipped at 1.
|
||
let hi = if depth == photocraft_color::SampleType::F32 { f32::MAX } else { 1.0 };
|
||
p[0] = [vibrance / 100.0, saturation / 100.0, transfer_exponent(transfer), hi];
|
||
let b = adjust::VIBRANCE_BOOST;
|
||
p[1] = [b[0], b[1], b[2], b[3]];
|
||
p[2] = [b[4], b[5], b[6], 0.0];
|
||
(9, p, None)
|
||
}
|
||
Adjustment::ChannelMixer { matrix, monochrome } => {
|
||
// compose::adjust: 32-bit documents cut each percentage to 1/1024, count 255/512 of
|
||
// the constant and don't clip (the shader checks F_HDR).
|
||
let hdr = depth == photocraft_color::SampleType::F32;
|
||
let cut = |v: f32| if hdr { (v * 1024.0).trunc() / 1024.0 } else { v };
|
||
let rows = matrix.map(|r| [cut(r[0]), cut(r[1]), cut(r[2]), if hdr { r[3] * 255.0 / 512.0 } else { r[3] }]);
|
||
p[0] = rows[0];
|
||
p[1] = rows[1];
|
||
p[2] = rows[2];
|
||
p[3][0] = if *monochrome { 1.0 } else { 0.0 };
|
||
(10, p, None)
|
||
}
|
||
Adjustment::PhotoFilter { color, density, preserve_luminosity } => {
|
||
// compose::adjust: the linear-light matrix, then SetLum on the encoded values (8/16-bit).
|
||
let linear_doc = depth == photocraft_color::SampleType::F32;
|
||
let m = adjust::photo_filter_matrix(*color, *density, *preserve_luminosity && linear_doc);
|
||
for (row, m) in p.iter_mut().zip(m) {
|
||
*row = [m[0], m[1], m[2], 0.0];
|
||
}
|
||
p[3] = [if *preserve_luminosity && !linear_doc { 1.0 } else { 0.0 }, transfer_exponent(transfer), 0.0, 0.0];
|
||
(11, p, None)
|
||
}
|
||
Adjustment::BlackWhite { weights, tint } => {
|
||
p[0] = [weights[0], weights[1], weights[2], weights[3]];
|
||
p[1] = [weights[4], weights[5], if tint.is_some() { 1.0 } else { 0.0 }, 0.0];
|
||
if let Some(t) = tint {
|
||
p[2] = [t[0], t[1], t[2], 0.0];
|
||
}
|
||
(12, p, None)
|
||
}
|
||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||
p[0][0] = if *reverse { 1.0 } else { 0.0 };
|
||
p[0][1] = if *dither { 1.0 } else { 0.0 };
|
||
let s4: Vec<(f32, [f32; 4])> = stops.iter().map(|(t, c)| (*t, [c[0], c[1], c[2], 1.0])).collect();
|
||
let rows = lut_rows(|r, t| if s4.is_empty() { t } else { sample_stops4(&s4, t)[r] }, 3);
|
||
(13, p, Some(rows))
|
||
}
|
||
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => {
|
||
// Per-channel curves (compose::adjust::color_balance_channel), through the Levels kernel.
|
||
let tones = [*shadows, *midtones, *highlights];
|
||
(7, p, Some(lut_rows(|ch, v| adjust::color_balance_channel(tones, *preserve_luminosity, ch, v), 3)))
|
||
}
|
||
Adjustment::SelectiveColor { relative, adjustments } => {
|
||
// 9 ranges × CMYK don't fit the 16 params: they go in LUT row 0 (read texel-exact).
|
||
p[0][0] = if *relative { 1.0 } else { 0.0 };
|
||
let mut row = [0.0f32; 4096];
|
||
for (i, v) in adjustments.iter().flatten().enumerate() {
|
||
row[i] = *v;
|
||
}
|
||
(15, p, Some(vec![row]))
|
||
}
|
||
Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if lut3d_len(*size).is_some_and(|len| table.len() >= len) => {
|
||
// The flattened table (n³ RGB triplets) spans as many 4096-wide rows as it needs.
|
||
let n = *size as usize;
|
||
let rows = lut3d_len(*size)
|
||
.and_then(|len| table.get(..len))
|
||
.unwrap_or_default()
|
||
.chunks(4096)
|
||
.map(|c| {
|
||
let mut row = [0.0f32; 4096];
|
||
row[..c.len()].copy_from_slice(c);
|
||
row
|
||
})
|
||
.collect();
|
||
p[0] = [n as f32, if *tetrahedral { 1.0 } else { 0.0 }, if *dither { 1.0 } else { 0.0 }, 0.0];
|
||
(16, p, Some(rows))
|
||
}
|
||
// Identity on the CPU too (not evaluated there).
|
||
Adjustment::ColorLookup { .. } | Adjustment::Unsupported { .. } => (0, p, None),
|
||
}
|
||
}
|
||
|
||
/// A gradient's colour and alpha as four 4096-entry LUT rows (`lut_tex`).
|
||
fn gradient_rows(g: &Gradient) -> Vec<[f32; 4096]> {
|
||
let mut rows = vec![[0.0f32; 4096]; 4];
|
||
for k in 0..4096 {
|
||
let v = photocraft_compose::effects::sample_gradient(g, k as f32 / 4095.0);
|
||
for (ch, row) in rows.iter_mut().enumerate() {
|
||
row[k] = v[ch];
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// Linear interpolation between LUT entries cannot preserve a discontinuity. Let the canvas
|
||
/// use the CPU oracle for coincident stops, including opacity stops and unsorted fill ramps
|
||
/// (which `gradient_fill::Ramp` sorts). Check only ramps we will sample: cached fills and
|
||
/// disabled effects can still use the GPU.
|
||
fn check_gradient_stops<T>(stops: &[(f32, T)]) -> Result<(), Unsupported> {
|
||
let mut positions: Vec<f32> = stops.iter().map(|s| s.0).collect();
|
||
positions.sort_by(f32::total_cmp);
|
||
// A 4096-entry LUT cannot keep two stops that fall in the same entry apart (#3053).
|
||
if positions.windows(2).any(|w| (w[1] - w[0]).abs() < 1.0 / 4095.0) {
|
||
return Err(Unsupported("coincident gradient stops (composited on the CPU)".into()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Mode index used by the shader (declaration order of [`BlendMode`]).
|
||
pub fn mode_index(m: BlendMode) -> i32 {
|
||
match m {
|
||
BlendMode::PassThrough => 0,
|
||
BlendMode::Normal => 1,
|
||
BlendMode::Dissolve => 2,
|
||
BlendMode::Darken => 3,
|
||
BlendMode::Multiply => 4,
|
||
BlendMode::ColorBurn => 5,
|
||
BlendMode::LinearBurn => 6,
|
||
BlendMode::DarkerColor => 7,
|
||
BlendMode::Lighten => 8,
|
||
BlendMode::Screen => 9,
|
||
BlendMode::ColorDodge => 10,
|
||
BlendMode::LinearDodge => 11,
|
||
BlendMode::LighterColor => 12,
|
||
BlendMode::Overlay => 13,
|
||
BlendMode::SoftLight => 14,
|
||
BlendMode::HardLight => 15,
|
||
BlendMode::VividLight => 16,
|
||
BlendMode::LinearLight => 17,
|
||
BlendMode::PinLight => 18,
|
||
BlendMode::HardMix => 19,
|
||
BlendMode::Difference => 20,
|
||
BlendMode::Exclusion => 21,
|
||
BlendMode::Subtract => 22,
|
||
BlendMode::Divide => 23,
|
||
BlendMode::Hue => 24,
|
||
BlendMode::Saturation => 25,
|
||
BlendMode::Color => 26,
|
||
BlendMode::Luminosity => 27,
|
||
BlendMode::Reflect => 28,
|
||
BlendMode::Glow => 29,
|
||
BlendMode::Negation => 30,
|
||
BlendMode::Xor => 31,
|
||
BlendMode::PaintNetColorBurn => 32,
|
||
BlendMode::PaintNetColorDodge => 33,
|
||
}
|
||
}
|
||
|
||
/// Whether `Planner::content` draws `layer` as one `Planner::content_pass`: not a group's stack,
|
||
/// an adjustment, or a pattern fill painted in passes.
|
||
fn single_content_pass(layer: &Layer) -> bool {
|
||
match &layer.content {
|
||
LayerContent::Group(_) | LayerContent::Adjustment(_) => false,
|
||
LayerContent::Fill(f @ Fill::Pattern { .. }) => layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f),
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use photocraft_color::{Color, ColorMode, SampleType};
|
||
use photocraft_geom::Size;
|
||
|
||
#[test]
|
||
fn stops_closer_than_one_lut_step_stay_on_the_cpu() {
|
||
assert!(check_gradient_stops(&[(0.4999_f32, ()), (0.5, ())]).is_err());
|
||
assert!(check_gradient_stops(&[(0.0_f32, ()), (1.0, ())]).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn lab_channel_adjustments_defer_to_the_cpu_in_lab_documents() {
|
||
let lab_rules = [
|
||
Adjustment::Invert,
|
||
Adjustment::Posterize { levels: 4 },
|
||
Adjustment::Threshold { level: 0.5 },
|
||
Adjustment::BrightnessContrast { brightness: 10.0, contrast: 5.0, legacy: false },
|
||
Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false },
|
||
];
|
||
for adj in &lab_rules {
|
||
assert!(!adjustment_on_gpu(adj, ColorMode::Lab), "{adj:?}");
|
||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||
assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}");
|
||
}
|
||
// CMYK documents invert, posterize, threshold and brighten their inks, on the CPU.
|
||
let inks = !matches!(adj, Adjustment::GradientMap { .. });
|
||
assert_eq!(adjustment_on_gpu(adj, ColorMode::Cmyk), !inks, "{adj:?} in CMYK");
|
||
}
|
||
// CMYK Hue/Saturation works on the inks (on the CPU); Colorize stays on the screen colour.
|
||
let ranges = photocraft_doc::adjust::HueRange::defaults();
|
||
let hue = |colorize| Adjustment::HueSaturation { hue: 30.0, saturation: 10.0, lightness: 0.0, colorize, ranges: ranges.clone() };
|
||
assert!(!adjustment_on_gpu(&hue(false), ColorMode::Cmyk) && adjustment_on_gpu(&hue(true), ColorMode::Cmyk));
|
||
assert!(adjustment_on_gpu(&hue(false), ColorMode::Rgb));
|
||
// Screen-colour adjustments stay on the GPU in Lab documents.
|
||
assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab));
|
||
}
|
||
|
||
#[test]
|
||
fn native_cmyk_fills_fall_back_only_without_a_valid_cache() {
|
||
use photocraft_doc::{FillCache, GradientStyle};
|
||
for depth in photocraft_color::SampleType::ALL {
|
||
for fill in [Fill::Solid(Color::WHITE), Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false)] {
|
||
let mut doc = Document::new("CMYK", photocraft_geom::Size::new(3, 2), photocraft_color::ColorMode::Cmyk, depth);
|
||
doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill.clone())));
|
||
assert!(plan(&doc).unwrap_err().0.contains("native CMYK fill"));
|
||
doc.layers[0].visible = false;
|
||
assert!(plan(&doc).is_ok());
|
||
doc.layers[0].visible = true;
|
||
doc.layers[0].fill_cache = Some(FillCache { fill, surface: photocraft_raster::Surface::new(doc.pixel_format()) });
|
||
assert!(plan(&doc).is_ok());
|
||
doc.layers[0].fill_cache.as_mut().unwrap().fill = Fill::Solid(Color::TRANSPARENT);
|
||
assert!(plan(&doc).is_err());
|
||
doc.mode = photocraft_color::ColorMode::Rgb;
|
||
assert!(plan(&doc).is_ok());
|
||
}
|
||
}
|
||
}
|
||
#[test]
|
||
fn outer_glow_plan_knocks_out_see_through_fill() {
|
||
use photocraft_doc::{Contour, FxCommon, GlowSource, GlowTechnique};
|
||
|
||
for fill in [0.0, 0.4, 1.0] {
|
||
let mut d = Document::with_background("glow", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut layer = Layer::raster("frame", d.pixel_format());
|
||
layer.surface_mut().unwrap().fill_rect(Rect::new(16, 16, 48, 48), &[1.0, 0.0, 0.0, 1.0]);
|
||
layer.fill_opacity = fill;
|
||
layer.effects.items.push(Effect::OuterGlow(Glow {
|
||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
|
||
technique: GlowTechnique::Softer,
|
||
spread: 0.3,
|
||
size: 8.0,
|
||
contour: Contour::Linear,
|
||
anti_alias: false,
|
||
range: 0.5,
|
||
jitter: 0.0,
|
||
noise: 0.0,
|
||
source: GlowSource::Edge,
|
||
}));
|
||
d.layers.push(layer);
|
||
let planned = plan(&d).unwrap();
|
||
let glow = planned
|
||
.passes
|
||
.iter()
|
||
.find(|pass| {
|
||
pass.kernel == Kernel::FxPaint && pass.flags & F_KNOCKOUT != 0 && pass.color[0] > 0.9 && pass.color[1] > 0.9 && pass.color[2] < 0.1
|
||
})
|
||
.expect("outer glow paint pass");
|
||
assert!((glow.extra[3] - (1.0 - fill)).abs() < 1e-6, "fill {fill}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() {
|
||
for size in [1 << 22, u32::MAX] {
|
||
let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false };
|
||
let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8);
|
||
assert_eq!((kind, rows.is_none()), (0, true), "size {size}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn root_checkpoints_have_only_one_live_input_and_keep_clips_whole() {
|
||
for mode in [BlendMode::Normal, BlendMode::PassThrough] {
|
||
let mut d = Document::with_background("t", Size::new(32, 24), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut clip = Layer::new("clip", LayerContent::Fill(Fill::Solid(Color::rgba(0.2, 0.3, 0.7, 0.4))));
|
||
clip.clipped = true;
|
||
let mut group = Layer::group(
|
||
"group",
|
||
vec![
|
||
Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.9, 0.1, 0.3, 0.6)))),
|
||
Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)),
|
||
],
|
||
);
|
||
group.blend = mode;
|
||
d.layers.extend([group, clip, Layer::new("top", LayerContent::Adjustment(Adjustment::Invert))]);
|
||
let plan = plan(&d).unwrap();
|
||
assert_eq!(plan.checkpoints.iter().map(|c| c.layer_end).collect::<Vec<_>>(), vec![1, 3, 4]);
|
||
for checkpoint in &plan.checkpoints {
|
||
let mut written = std::collections::HashSet::from([checkpoint.slot]);
|
||
for pass in &plan.passes[checkpoint.pass_end..] {
|
||
for input in [pass.a, pass.b, pass.c, pass.d].into_iter().flatten() {
|
||
assert!(written.contains(&input), "a suffix must not need another saved slot: {checkpoint:?}");
|
||
}
|
||
written.insert(pass.dst);
|
||
}
|
||
assert!(written.contains(&plan.root));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn slots_are_recycled() {
|
||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
for i in 0..10 {
|
||
d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format()));
|
||
}
|
||
let p = plan(&d).unwrap();
|
||
// Empty layers draw nothing: the clear and the background's one fused pass.
|
||
assert_eq!(p.passes.len(), 1 + 1);
|
||
for (i, l) in d.layers.iter_mut().skip(1).enumerate() {
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||
}
|
||
let p = plan(&d).unwrap();
|
||
assert!(p.slots <= 3, "{} slots", p.slots);
|
||
// The background, then each small layer blended over its bounds and copied back.
|
||
assert_eq!(p.passes.len(), 1 + 1 + 10 * 2);
|
||
}
|
||
|
||
#[test]
|
||
fn small_layers_composite_only_over_their_bounds() {
|
||
let mut d = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut l = Layer::raster("small", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 12, 20, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||
let mut group = Layer::group("g", vec![l]);
|
||
group.blend = BlendMode::Normal;
|
||
d.layers.push(group);
|
||
let p = plan(&d).unwrap();
|
||
let r = Rect::new(10, 12, 20, 30);
|
||
// The group's content, its blend and the copy back are all limited to the layer.
|
||
assert!(p.passes.iter().filter(|p| matches!(p.kernel, Kernel::Content | Kernel::Blend)).skip(2).all(|p| p.clip == Some(r)), "{:?}", p.passes);
|
||
let last = p.passes.last().unwrap();
|
||
assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(r)));
|
||
// A layer that covers most of the canvas composites everywhere.
|
||
d.layers[1].children_mut().unwrap()[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 60, 60), &[1.0, 0.0, 0.0, 1.0]);
|
||
let p = plan(&d).unwrap();
|
||
assert!(p.passes.iter().all(|p| p.clip.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn effects_are_planned_in_place() {
|
||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut l = Layer::raster("fx", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(2, 2, 5, 5), &[1.0, 0.0, 0.0, 1.0]);
|
||
l.effects.items.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE });
|
||
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||
d.layers.push(l);
|
||
let p = plan(&d).unwrap();
|
||
assert_eq!(p.fx.len(), 1);
|
||
let m = photocraft_compose::effects::margin(&d.layers[1]);
|
||
assert_eq!(p.fx[0].region, Rect::new(2, 2, 5, 5).inflate(m).intersect(&d.bounds().inflate(m)));
|
||
// The effect region covers the whole canvas: the merged result is the backdrop, no copy.
|
||
assert!(p.fx[0].region.contains_rect(&d.bounds()));
|
||
let last = p.passes.last().unwrap();
|
||
assert_eq!(last.kernel, Kernel::FxMerge);
|
||
assert_eq!(last.dst, p.root);
|
||
assert!(p.slots <= 6, "{} slots", p.slots);
|
||
// On a larger canvas the result lands back in the backdrop slot over the region only.
|
||
let mut big = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
big.layers.push(d.layers[1].clone());
|
||
let p = plan(&big).unwrap();
|
||
assert!(!p.fx[0].region.contains_rect(&big.bounds()));
|
||
let last = p.passes.last().unwrap();
|
||
assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(p.fx[0].region)));
|
||
}
|
||
|
||
#[test]
|
||
fn exterior_effects_merge_from_the_shape() {
|
||
// A drop shadow alone: the layer's pixels, the shadow, the merge. With an interior effect
|
||
// the layer is built in passes of its own (colour inside the shape, the overlay, alpha).
|
||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut l = Layer::raster("fx", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 0.5]);
|
||
l.fill_opacity = 0.7;
|
||
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||
d.layers.push(l);
|
||
let p = plan(&d).unwrap();
|
||
let fx: Vec<Kernel> = p.passes.iter().skip(2).map(|p| p.kernel).collect();
|
||
assert_eq!(fx, [Kernel::Content, Kernel::FxPaint, Kernel::FxMerge], "{:?}", p.passes);
|
||
let merge = p.passes.last().unwrap();
|
||
assert!(merge.flags & F_LAYER_SHAPE != 0);
|
||
assert_eq!(merge.params[0][0], 0.7);
|
||
d.layers[1]
|
||
.effects
|
||
.items
|
||
.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::WHITE });
|
||
let p = plan(&d).unwrap();
|
||
assert!(p.passes.iter().all(|p| p.flags & F_LAYER_SHAPE == 0));
|
||
assert!(p.passes.iter().any(|p| p.kernel == Kernel::FxInit));
|
||
}
|
||
|
||
#[test]
|
||
fn opaque_fill_layers_skip_what_they_cover() {
|
||
let opaque = || Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 30.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
for i in 0..4 {
|
||
// Small painted layers (empty ones plan no passes at all).
|
||
let mut l = Layer::raster(format!("l{i}"), d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(Rect::new(i, i, i + 2, i + 2), &[0.0, 0.0, 1.0, 1.0]);
|
||
d.layers.push(l);
|
||
}
|
||
d.layers.push(Layer::new("g", LayerContent::Fill(opaque())));
|
||
let mut top = Layer::raster("top", d.pixel_format());
|
||
top.clipped = true;
|
||
d.layers.push(top);
|
||
// Clear, then the fill and its clipped layer: the background and four rasters are skipped.
|
||
let skipped = plan(&d).unwrap().passes.len();
|
||
let mut alone = d.clone();
|
||
alone.layers.drain(..5);
|
||
assert_eq!(skipped, plan(&alone).unwrap().passes.len());
|
||
// Anything that lets the layers below show through keeps them.
|
||
let see_through: [fn(&mut Layer); 6] = [
|
||
|l| l.opacity = 0.99,
|
||
|l| l.fill_opacity = 0.5,
|
||
|l| l.blend = BlendMode::Multiply,
|
||
|l| l.visible = false,
|
||
|l| l.excluded_channels = 1,
|
||
|l| {
|
||
if let LayerContent::Fill(Fill::Gradient { opacity_stops, .. }) = &mut l.content {
|
||
opacity_stops.push((1.0, 0.5));
|
||
}
|
||
},
|
||
];
|
||
for (k, f) in see_through.iter().enumerate() {
|
||
let mut d2 = d.clone();
|
||
f(&mut d2.layers[5]);
|
||
assert!(plan(&d2).unwrap().passes.len() > skipped, "case {k}: the layers below are planned");
|
||
assert!(!photocraft_compose::occludes_below(&d2.layers[5], ColorMode::Rgb), "case {k}");
|
||
}
|
||
let mut translucent = d.clone();
|
||
translucent.layers[5].content = LayerContent::Fill(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 0.5)));
|
||
assert!(!photocraft_compose::occludes_below(&translucent.layers[5], ColorMode::Rgb));
|
||
assert!(photocraft_compose::occludes_below(&d.layers[5], ColorMode::Rgb));
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_plans_on_the_gpu() {
|
||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut l = Layer::raster("bi", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||
l.blend_if.set(0, [photocraft_doc::BlendRange { black: [40, 40], white: [255, 255] }, photocraft_doc::BlendRange::FULL]);
|
||
d.layers.push(l.clone());
|
||
let blend_if = |d: &Document| plan(d).unwrap().passes.iter().filter(|p| p.flags & F_BLEND_IF != 0).count();
|
||
assert_eq!(blend_if(&d), 1);
|
||
// Clipped layers too.
|
||
l.clipped = true;
|
||
d.layers[1].blend_if = Default::default();
|
||
d.layers.push(l);
|
||
assert_eq!(blend_if(&d), 1);
|
||
// Outside RGB, Grayscale and Duotone the setting round-trips without applying.
|
||
d.mode = ColorMode::Lab;
|
||
assert_eq!(blend_if(&d), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn blend_if_params_pack_every_range_exactly() {
|
||
let mut bi = photocraft_doc::BlendIf::default();
|
||
bi.set(3, [photocraft_doc::BlendRange { black: [255, 255], white: [0, 1] }, photocraft_doc::BlendRange { black: [12, 200], white: [201, 254] }]);
|
||
let f: Vec<f32> = blend_if_params(&bi).iter().flatten().copied().collect();
|
||
// Entry 3 (B), this layer then underlying; unset entries are full ranges.
|
||
assert_eq!(&f[12..16], &[255.0 * 256.0 + 255.0, 1.0, 12.0 * 256.0 + 200.0, 201.0 * 256.0 + 254.0]);
|
||
assert_eq!(&f[0..2], &[0.0, 255.0 * 256.0 + 255.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_blending_falls_back_to_the_cpu() {
|
||
use photocraft_doc::Knockout;
|
||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||
let mut l = Layer::raster("ko", d.pixel_format());
|
||
l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||
d.layers.push(l.clone());
|
||
assert!(plan(&d).is_ok(), "defaults stay on the GPU");
|
||
// Switches with nothing to act on (no effects, no clipped layers) stay on the GPU too.
|
||
d.layers[1].advanced.blend_interior = true;
|
||
d.layers[1].advanced.blend_clipped = false;
|
||
assert!(plan(&d).is_ok());
|
||
d.layers[1].advanced.knockout = Knockout::Deep;
|
||
assert!(plan(&d).unwrap_err().0.contains("Advanced Blending"));
|
||
// Inside groups and clipping groups.
|
||
d.layers[1].advanced.knockout = Knockout::None;
|
||
let mut c = l.clone();
|
||
c.clipped = true;
|
||
d.layers.push(c.clone());
|
||
assert!(plan(&d).is_err(), "clipped layers blended individually");
|
||
d.layers[1].advanced.blend_clipped = true;
|
||
assert!(plan(&d).is_ok());
|
||
d.layers[2].advanced.knockout = Knockout::Shallow;
|
||
assert!(plan(&d).is_err(), "clipped knockout");
|
||
d.layers.truncate(1);
|
||
let mut inner = l;
|
||
inner.advanced.knockout = Knockout::Shallow;
|
||
d.layers.push(Layer::group("g", vec![inner]));
|
||
assert!(plan(&d).is_err(), "knockout inside a group");
|
||
}
|
||
}
|