Files
hcie-rust-v3.05/hcie-engine-api/src/partial_composite.rs
T

532 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;
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
);
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
}