538 lines
22 KiB
Rust
538 lines
22 KiB
Rust
//! Partial / dirty-region compositing for the HCIE engine.
|
|
//!
|
|
//! ## Purpose
|
|
//! Contains the functions that turn layer pixel data into a flat composite
|
|
//! image: `render_composite_region()` for GUI partial updates,
|
|
//! `get_composite_pixels()` for full-canvas exports, and the incremental
|
|
//! tile synchroniser `sync_dirty_tiles()`. These are the most performance-
|
|
//! sensitive composite paths.
|
|
//!
|
|
//! ## Logic & Workflow
|
|
//! 1. Render dirty vector layers into their pixel buffers.
|
|
//! 2. Apply layer effects/styles when `effects_dirty` is set, caching the
|
|
//! result in `layer.effects_cache`.
|
|
//! 3. Sync the sparse tile cache for any dirty layers (`sync_dirty_tiles`).
|
|
//! 4. Composite either the dirty sub-region or the full canvas using the
|
|
//! tiled compositor, optionally reusing the `below_cache` snapshot of
|
|
//! layers below the active layer.
|
|
//!
|
|
//! ## Side Effects / Dependencies
|
|
//! Mutates layer pixels, `tile_layers`, `composite_scratch`, `raw_pixel_backup`,
|
|
//! and dirty flags. Depends on `hcie_tile::TiledLayer`, `hcie_fx`, and the
|
|
//! dynamic `tiled::composite_tiled_into` / `composite_layers` compositors.
|
|
|
|
use crate::dynamic_loader::vector::render_vector_shapes;
|
|
use crate::dynamic_loader::{composite_layers, tiled};
|
|
use crate::Engine;
|
|
use hcie_tile::TiledLayer;
|
|
use rayon::prelude::*;
|
|
|
|
impl Engine {
|
|
/// **Purpose:**
|
|
/// Computes the flat composite RGBA pixel buffer of all layers in the document.
|
|
///
|
|
/// **Logic & Workflow:**
|
|
/// 1. Renders vector shapes for dirty vector layers.
|
|
/// 2. Applies layer effects/styles if they are dirty, caching the result.
|
|
/// 3. Synchronizes `self.tile_layers` for any dirty layers.
|
|
/// 4. Clears dirty flags.
|
|
/// 5. Blends all layers into a flat caller-allocated buffer.
|
|
pub fn get_composite_pixels(&mut self) -> Vec<u8> {
|
|
log::trace!("[get_composite_pixels] ===== START =====");
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|
log::trace!(
|
|
"[get_composite_pixels] layer[{}] id={} name='{}' visible={} dirty={} opacity={} blend={:?}",
|
|
i, l.id, l.name, l.visible, l.dirty, l.opacity, l.blend_mode
|
|
);
|
|
}
|
|
self.apply_effects_and_sync_tiles();
|
|
self.document.clear_dirty();
|
|
let output = composite_layers(
|
|
&self.document.layers,
|
|
self.document.canvas_width,
|
|
self.document.canvas_height,
|
|
);
|
|
let non_zero_alpha = output.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
|
let total_pixels = output.len() / 4;
|
|
log::trace!(
|
|
"[get_composite_pixels] completed full composite: size={} bytes, non_zero_alpha={}/{} ({}%), first_pixel={:?}",
|
|
output.len(),
|
|
non_zero_alpha,
|
|
total_pixels,
|
|
if total_pixels > 0 { non_zero_alpha * 100 / total_pixels } else { 0 },
|
|
if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] }
|
|
);
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|
let opaque_count = l
|
|
.pixels
|
|
.iter()
|
|
.skip(3)
|
|
.step_by(4)
|
|
.filter(|&&a| a > 0)
|
|
.count();
|
|
let total = l.pixels.len() / 4;
|
|
log::trace!(
|
|
"[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)",
|
|
i, l.id, l.name, l.visible, l.pixels.len(),
|
|
opaque_count, total,
|
|
if total > 0 { opaque_count * 100 / total } else { 0 }
|
|
);
|
|
}
|
|
output
|
|
}
|
|
|
|
pub fn render_composite(&mut self) -> Vec<u8> {
|
|
self.get_composite_pixels()
|
|
}
|
|
|
|
/// Composite only the dirty region into a pooled internal scratch buffer.
|
|
/// Returns the dirty bounds `[x0, y0, x1, y1]` that were updated,
|
|
/// or `None` if nothing was dirty (full composite needed).
|
|
pub fn render_composite_region(&mut self) -> (Option<[u32; 4]>, *const u8, usize) {
|
|
let has_dirty = self.document.composite_dirty;
|
|
let dirty = self.document.dirty_bounds;
|
|
let w = self.document.canvas_width;
|
|
let h = self.document.canvas_height;
|
|
|
|
if !has_dirty && dirty.is_none() {
|
|
log::trace!("[render_composite_region] nothing dirty — returning None");
|
|
return (None, std::ptr::null(), 0);
|
|
}
|
|
|
|
if has_dirty && dirty.is_none() {
|
|
// composite_dirty was set (e.g., after loading/importing) but no layer-level
|
|
// dirty_bounds exist. Force a full-canvas composite so the caller sees content
|
|
// instead of an empty/null region.
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|
}
|
|
log::trace!(
|
|
"[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====",
|
|
has_dirty,
|
|
dirty
|
|
);
|
|
let w = self.document.canvas_width;
|
|
let h = self.document.canvas_height;
|
|
let buf_size = (w * h * 4) as usize;
|
|
|
|
self.apply_effects_and_sync_tiles();
|
|
|
|
let (x0, y0, x1, y1) = match dirty {
|
|
Some([x0, y0, x1, y1]) => (x0.min(w), y0.min(h), x1.min(w), y1.min(h)),
|
|
None => (0, 0, w, h),
|
|
};
|
|
|
|
// Now safe to borrow composite_scratch — no more mutable calls to self
|
|
// that could touch this buffer until we return.
|
|
if self
|
|
.composite_scratch
|
|
.as_ref()
|
|
.map_or(true, |b| b.len() != buf_size)
|
|
{
|
|
self.composite_scratch = Some(vec![0u8; buf_size]);
|
|
}
|
|
let buf = self.composite_scratch.as_mut().unwrap();
|
|
|
|
if x1 > x0 && y1 > y0 {
|
|
let wu = w as usize;
|
|
let x0u = x0 as usize;
|
|
|
|
let active_idx = self.document.active_layer;
|
|
let cache_valid = !self.below_cache_dirty
|
|
&& self.below_cache.is_some()
|
|
&& self.below_cache_active_idx == Some(active_idx)
|
|
&& active_idx > 0;
|
|
|
|
log::trace!(
|
|
"[render_composite_region] BEFORE composite: active_idx={}, cache_valid={}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}, dirty_rect=[{},{},{},{}]",
|
|
active_idx, cache_valid, self.below_cache.is_some(), self.below_cache_active_idx, self.tile_layers.len(), x0, y0, x1, y1
|
|
);
|
|
if log::log_enabled!(log::Level::Trace) {
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|
let tc = self
|
|
.tile_layers
|
|
.get(i)
|
|
.and_then(|t| t.as_ref().map(|tl| tl.tile_count()))
|
|
.unwrap_or(0);
|
|
log::trace!(
|
|
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
|
|
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
|
|
);
|
|
}
|
|
}
|
|
|
|
if cache_valid {
|
|
let cache = self.below_cache.as_ref().unwrap();
|
|
if log::log_enabled!(log::Level::Trace) {
|
|
let below_non_zero = cache.iter().filter(|&&b| b != 0).count();
|
|
log::trace!(
|
|
"[render_composite_region] CACHE HIT: using below_cache ({} non-zero bytes), compositing layers[{}..{}] on top",
|
|
below_non_zero, active_idx, self.document.layers.len()
|
|
);
|
|
}
|
|
for y in y0..y1 {
|
|
let start = (y as usize * wu + x0u) * 4;
|
|
let end = start + ((x1 - x0) as usize) * 4;
|
|
if end <= cache.len() && end <= buf.len() {
|
|
buf[start..end].copy_from_slice(&cache[start..end]);
|
|
}
|
|
}
|
|
let tile_start = active_idx.min(self.tile_layers.len());
|
|
tiled::composite_tiled_into(
|
|
&self.document.layers[active_idx..],
|
|
&self.tile_layers[tile_start..],
|
|
w,
|
|
h,
|
|
x0,
|
|
y0,
|
|
x1,
|
|
y1,
|
|
buf,
|
|
);
|
|
log::trace!(
|
|
"[render_composite_region] cache hit DONE: {} above layers composited, dirty_rect=[{},{},{},{}]",
|
|
self.document.layers.len() - active_idx, x0, y0, x1, y1
|
|
);
|
|
} else {
|
|
log::trace!(
|
|
"[render_composite_region] CACHE MISS: full composite of all {} layers, dirty_rect=[{},{},{},{}]",
|
|
self.document.layers.len(), x0, y0, x1, y1
|
|
);
|
|
let is_full_canvas = x0 == 0 && y0 == 0 && x1 == w && y1 == h;
|
|
if is_full_canvas {
|
|
buf.par_chunks_mut(65536).for_each(|chunk| chunk.fill(0));
|
|
} else {
|
|
for y in y0..y1 {
|
|
let start = (y as usize * wu + x0u) * 4;
|
|
let end = start + ((x1 - x0) as usize) * 4;
|
|
buf[start..end].fill(0);
|
|
}
|
|
}
|
|
tiled::composite_tiled_into(
|
|
&self.document.layers,
|
|
&self.tile_layers,
|
|
w,
|
|
h,
|
|
x0,
|
|
y0,
|
|
x1,
|
|
y1,
|
|
buf,
|
|
);
|
|
log::trace!(
|
|
"[render_composite_region] full composite DONE, dirty_rect=[{},{},{},{}]",
|
|
x0,
|
|
y0,
|
|
x1,
|
|
y1
|
|
);
|
|
}
|
|
}
|
|
|
|
self.document.clear_dirty();
|
|
let ptr = buf.as_ptr();
|
|
(Some([x0, y0, x1, y1]), ptr, buf_size)
|
|
}
|
|
|
|
/// Shared preprocessing for full and partial compositing:
|
|
/// - render dirty vector shapes,
|
|
/// - apply dirty layer effects/styles,
|
|
/// - sync the sparse tile cache.
|
|
fn apply_effects_and_sync_tiles(&mut self) {
|
|
// Render vector shapes before compositing — only dirty vector layers
|
|
for layer in &mut self.document.layers {
|
|
if let hcie_protocol::LayerData::Vector { shapes: _ } = &layer.data {
|
|
if layer.dirty {
|
|
layer.pixels.fill(0);
|
|
render_vector_shapes(layer);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Effects pipeline (two-pass) ─────────────────────────────────────────────
|
|
// Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet
|
|
// have a backup.
|
|
{
|
|
let ids_needing_backup: Vec<(u64, Vec<u8>)> = self
|
|
.document
|
|
.layers
|
|
.iter()
|
|
.filter(|l| {
|
|
(!l.effects.is_empty() || !l.styles.is_empty())
|
|
&& !l.pixels.is_empty()
|
|
&& !self.raw_pixel_backup.contains_key(&l.id)
|
|
})
|
|
.map(|l| (l.id, l.pixels.clone()))
|
|
.collect();
|
|
for (id, pixels) in ids_needing_backup {
|
|
self.raw_pixel_backup.insert(id, pixels);
|
|
}
|
|
}
|
|
|
|
// Pass 2: Apply layer effects. Restores from backup first so that each
|
|
// slider edit always applies on top of the original untouched pixels.
|
|
// CRITICAL: Do NOT modify layer.pixels with effects output. layer.pixels
|
|
// must always contain the raw drawing data so that new strokes are drawn
|
|
// on top of clean pixels (not on effects-applied pixels). The composite
|
|
// uses effects_cache for layers with effects, not layer.pixels.
|
|
for layer in &mut self.document.layers {
|
|
if layer.effects.is_empty() && layer.styles.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Check if there are any active/enabled effects or styles
|
|
let has_enabled = layer.effects.iter().any(|e| match e {
|
|
hcie_protocol::effects::LayerEffect::DropShadow { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::InnerShadow { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::OuterGlow { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::InnerGlow { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::BevelEmboss { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::Satin { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::ColorOverlay { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::GradientOverlay { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::PatternOverlay { enabled, .. }
|
|
| hcie_protocol::effects::LayerEffect::Stroke { enabled, .. } => *enabled,
|
|
}) || layer.styles.iter().any(|s| match s {
|
|
hcie_protocol::LayerStyle::DropShadow { enabled, .. }
|
|
| hcie_protocol::LayerStyle::InnerShadow { enabled, .. }
|
|
| hcie_protocol::LayerStyle::OuterGlow { enabled, .. }
|
|
| hcie_protocol::LayerStyle::InnerGlow { enabled, .. }
|
|
| hcie_protocol::LayerStyle::BevelEmboss { enabled, .. }
|
|
| hcie_protocol::LayerStyle::Satin { enabled, .. }
|
|
| hcie_protocol::LayerStyle::ColorOverlay { enabled, .. }
|
|
| hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
|
|
| hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
|
|
| hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
|
|
});
|
|
|
|
if !has_enabled {
|
|
// No active effects: clean up cached rendering and backup to restore raw performance.
|
|
*layer.effects_cache.lock().unwrap() = None;
|
|
self.raw_pixel_backup.remove(&layer.id);
|
|
layer
|
|
.effects_dirty
|
|
.store(false, std::sync::atomic::Ordering::Release);
|
|
layer.dirty = true;
|
|
continue;
|
|
}
|
|
|
|
if layer
|
|
.effects_dirty
|
|
.load(std::sync::atomic::Ordering::Acquire)
|
|
{
|
|
log::trace!(
|
|
"Applying active effects/styles for layer ID {} because effects are dirty",
|
|
layer.id
|
|
);
|
|
// DON'T restore raw pixels from backup! Restoring raw pixels from backup overwrites
|
|
// active stroke drawing and wipes out new strokes. Since we never overwrite layer.pixels
|
|
// with the effects output, layer.pixels already contains the clean, raw pixels.
|
|
let mut effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter()
|
|
.filter(|e| !matches!(e, hcie_protocol::effects::LayerEffect::DropShadow { noise, .. } if *noise == -999.0))
|
|
.map(|e| hcie_fx::protocol_to_hcie_fx_effect(e))
|
|
.collect();
|
|
effects.extend(
|
|
layer
|
|
.styles
|
|
.iter()
|
|
.filter_map(|s| hcie_fx::layer_style_to_effect(s)),
|
|
);
|
|
if effects.is_empty() {
|
|
continue;
|
|
}
|
|
let processed = hcie_fx::apply_layer_effects(
|
|
&layer.pixels,
|
|
layer.width,
|
|
layer.height,
|
|
&effects,
|
|
layer.fill_opacity,
|
|
);
|
|
*layer.effects_cache.lock().unwrap() = Some(hcie_protocol::LayerEffects {
|
|
rendered: processed.clone(),
|
|
width: layer.width,
|
|
height: layer.height,
|
|
});
|
|
layer
|
|
.effects_dirty
|
|
.store(false, std::sync::atomic::Ordering::Release);
|
|
layer.dirty = true;
|
|
}
|
|
}
|
|
|
|
self.sync_dirty_tiles();
|
|
}
|
|
|
|
/// Incremental tile cache update.
|
|
/// For dirty layers, only re-tile the tiles overlapping the global
|
|
/// dirty_bounds region instead of scanning the entire 33MB dense buffer.
|
|
/// Falls back to full from_dense() when dirty_bounds is None.
|
|
fn sync_dirty_tiles(&mut self) {
|
|
let count = self.document.layers.len();
|
|
if self.tile_layers.len() < count {
|
|
self.tile_layers.resize_with(count, || None);
|
|
}
|
|
let db = self.document.dirty_bounds;
|
|
let mut synced_count = 0usize;
|
|
for (i, layer) in self.document.layers.iter().enumerate() {
|
|
if !layer.dirty || layer.pixels.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some([dx0, dy0, dx1, dy1]) = db {
|
|
if let Some(ref mut tl) = self.tile_layers[i] {
|
|
if tl.width() == layer.width && tl.height() == layer.height {
|
|
tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1);
|
|
log::trace!(
|
|
"[sync_dirty_tiles] incremental update layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]",
|
|
i, layer.id, layer.visible, dx0, dy0, dx1, dy1
|
|
);
|
|
} else {
|
|
*tl = TiledLayer::from_dense(&layer.pixels, layer.width, layer.height);
|
|
log::trace!(
|
|
"[sync_dirty_tiles] size mismatch - full rebuild TiledLayer for layer[{}] id={} visible={} bounds=[{},{}]",
|
|
i, layer.id, layer.visible, layer.width, layer.height
|
|
);
|
|
}
|
|
} else {
|
|
let mut tl = TiledLayer::new(layer.width, layer.height);
|
|
tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1);
|
|
self.tile_layers[i] = Some(tl);
|
|
log::trace!(
|
|
"[sync_dirty_tiles] created new TiledLayer for layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]",
|
|
i, layer.id, layer.visible, dx0, dy0, dx1, dy1
|
|
);
|
|
}
|
|
} else {
|
|
self.tile_layers[i] = Some(TiledLayer::from_dense(
|
|
&layer.pixels,
|
|
layer.width,
|
|
layer.height,
|
|
));
|
|
log::trace!(
|
|
"[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)",
|
|
i, layer.id, layer.visible
|
|
);
|
|
}
|
|
synced_count += 1;
|
|
}
|
|
self.tile_layers.truncate(count);
|
|
if synced_count > 0 {
|
|
log::trace!(
|
|
"[sync_dirty_tiles] synced {} layers, db={:?}, tile_layers_len={}",
|
|
synced_count,
|
|
db,
|
|
self.tile_layers.len()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Snapshot of layer data needed for background compositing.
|
|
///
|
|
/// # Purpose
|
|
/// Captures all state required to composite the canvas without borrowing
|
|
/// `&mut Engine`. The background thread uses this snapshot to compute the
|
|
/// composite independently of the UI thread.
|
|
///
|
|
/// # Logic & Workflow
|
|
/// 1. `Engine::create_composite_snapshot()` clones layer pixels, properties,
|
|
/// and tile data into this struct.
|
|
/// 2. `composite_from_snapshot()` reads the snapshot and produces the flat
|
|
/// RGBA composite buffer on a background thread.
|
|
pub struct CompositeSnapshot {
|
|
pub layers: Vec<hcie_protocol::Layer>,
|
|
pub tile_layers: Vec<Option<TiledLayer>>,
|
|
pub canvas_width: u32,
|
|
pub canvas_height: u32,
|
|
pub active_layer: usize,
|
|
}
|
|
|
|
impl Engine {
|
|
/// Create a snapshot of the current layer state for background compositing.
|
|
///
|
|
/// # Purpose
|
|
/// Clones all layer pixels, properties, and tile data so a background
|
|
/// thread can compute the composite without borrowing the engine.
|
|
///
|
|
/// # Side Effects
|
|
/// Calls `apply_effects_and_sync_tiles()` to ensure dirty layers are
|
|
/// up-to-date before cloning. This mutates engine state but is safe
|
|
/// because it's called on the UI thread before spawning the background.
|
|
pub fn create_composite_snapshot(&mut self) -> CompositeSnapshot {
|
|
// Ensure all dirty layers are up-to-date before cloning
|
|
self.apply_effects_and_sync_tiles();
|
|
|
|
let layers = self.document.layers.clone();
|
|
let tile_layers = self.tile_layers.clone();
|
|
let canvas_width = self.document.canvas_width;
|
|
let canvas_height = self.document.canvas_height;
|
|
let active_layer = self.document.active_layer;
|
|
|
|
CompositeSnapshot {
|
|
layers,
|
|
tile_layers,
|
|
canvas_width,
|
|
canvas_height,
|
|
active_layer,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute the flat composite RGBA buffer from a snapshot.
|
|
///
|
|
/// # Purpose
|
|
/// Performs the full canvas composite on a background thread using the
|
|
/// cloned layer data from `CompositeSnapshot`. This function is standalone
|
|
/// (not a method on `Engine`) so it can be called from a background thread
|
|
/// without borrowing the engine.
|
|
///
|
|
/// # Arguments
|
|
/// * `snapshot` — Cloned layer data from `create_composite_snapshot()`.
|
|
///
|
|
/// # Returns
|
|
/// A `Vec<u8>` of size `width * height * 4` containing the flat RGBA composite.
|
|
pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
|
|
let w = snapshot.canvas_width;
|
|
let h = snapshot.canvas_height;
|
|
let buf_size = (w * h * 4) as usize;
|
|
|
|
// Build tile slices for the compositor
|
|
let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len());
|
|
let _visible_below: Vec<(usize, bool)> = snapshot.layers[..snapshot.active_layer]
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, l)| (i, l.visible))
|
|
.collect();
|
|
|
|
let mut buf = vec![0u8; buf_size];
|
|
|
|
// Composite below layers first (if any)
|
|
if snapshot.active_layer > 0 {
|
|
tiled::composite_tiled_into(
|
|
&snapshot.layers[..snapshot.active_layer],
|
|
&snapshot.tile_layers[..tile_slice_len],
|
|
w,
|
|
h,
|
|
0,
|
|
0,
|
|
w,
|
|
h,
|
|
&mut buf,
|
|
);
|
|
}
|
|
|
|
// Composite active layer and above
|
|
let tile_start = snapshot.active_layer.min(snapshot.tile_layers.len());
|
|
tiled::composite_tiled_into(
|
|
&snapshot.layers[snapshot.active_layer..],
|
|
&snapshot.tile_layers[tile_start..],
|
|
w,
|
|
h,
|
|
0,
|
|
0,
|
|
w,
|
|
h,
|
|
&mut buf,
|
|
);
|
|
|
|
buf
|
|
}
|