From ec0e10392709b2d4a21ab8241972f8875af6d75f Mon Sep 17 00:00:00 2001 From: halfsweet Date: Fri, 24 Jul 2026 14:34:25 +0800 Subject: [PATCH 1/4] feat(core): add incremental damage rendering --- engine/core/src/damage.rs | 821 ++++++++++++++++++++++++++++++++++++++ engine/core/src/lib.rs | 23 +- engine/core/src/raster.rs | 568 +++++++++++++++++++++++++- 3 files changed, 1409 insertions(+), 3 deletions(-) create mode 100644 engine/core/src/damage.rs diff --git a/engine/core/src/damage.rs b/engine/core/src/damage.rs new file mode 100644 index 00000000..c6f839b8 --- /dev/null +++ b/engine/core/src/damage.rs @@ -0,0 +1,821 @@ +//! Backend-independent DrawList damage tracking. +//! +//! A [`DamageTracker`] snapshots the DrawList whose pixels currently live in +//! one persistent render target. On the next frame it compares operations in +//! order, adds both the old and new conservative bounds for changed +//! operations, and returns a small set of disjoint rectangles. Backends must +//! clear each returned rectangle and replay the complete current DrawList +//! clipped to that rectangle so painter order and translucent overlays remain +//! correct. +//! +//! Keep one tracker per physical framebuffer. Core-managed texture, font and +//! style mutations are detected through [`Ui::raster_revision`]. Hosts must +//! still call [`DamageTracker::invalidate`] for output-affecting mutations +//! performed outside `Ui`. + +use alloc::vec::Vec; + +use crate::{spec, Ui}; + +const CLIP_DEPTH: usize = 32; + +/// Default fixed capacity used by the generic software rasterizer. +pub const DEFAULT_DAMAGE_REGIONS: usize = 8; + +/// Failure to plan damage for a persistent target. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DamageError { + InvalidCapacity, + InvalidPolicy, + InvalidTarget, + MalformedDrawList, +} + +/// Integer logical-pixel rectangle with half-open bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DamageRect { + pub x0: i32, + pub y0: i32, + pub x1: i32, + pub y1: i32, +} + +impl DamageRect { + pub const fn new(x0: i32, y0: i32, x1: i32, y1: i32) -> Self { + Self { x0, y0, x1, y1 } + } + + pub const fn empty() -> Self { + Self { + x0: i32::MAX, + y0: i32::MAX, + x1: i32::MIN, + y1: i32::MIN, + } + } + + pub fn intersect(self, other: Self) -> Self { + Self { + x0: self.x0.max(other.x0), + y0: self.y0.max(other.y0), + x1: self.x1.min(other.x1), + y1: self.y1.min(other.y1), + } + } + + pub fn union(self, other: Self) -> Self { + if self.is_empty() { + return other; + } + if other.is_empty() { + return self; + } + Self { + x0: self.x0.min(other.x0), + y0: self.y0.min(other.y0), + x1: self.x1.max(other.x1), + y1: self.y1.max(other.y1), + } + } + + pub fn is_empty(self) -> bool { + self.x0 >= self.x1 || self.y0 >= self.y1 + } + + pub fn area(self) -> u64 { + if self.is_empty() { + 0 + } else { + (self.x1 - self.x0) as u64 * (self.y1 - self.y0) as u64 + } + } + + fn touches(self, other: Self) -> bool { + !self.is_empty() + && !other.is_empty() + && self.x0 <= other.x1 + && other.x0 <= self.x1 + && self.y0 <= other.y1 + && other.y0 <= self.y1 + } +} + +impl Default for DamageRect { + fn default() -> Self { + Self::empty() + } +} + +/// Backend policy for promoting accumulated damage to a full redraw. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DamagePolicy { + /// Promote when damaged area reaches this percentage of the viewport. + /// Valid values are 1 through 100. + pub full_redraw_percent: u8, +} + +impl DamagePolicy { + pub const fn new(full_redraw_percent: u8) -> Self { + Self { + full_redraw_percent, + } + } + + fn is_valid(self) -> bool { + (1..=100).contains(&self.full_redraw_percent) + } +} + +impl Default for DamagePolicy { + fn default() -> Self { + Self::new(75) + } +} + +/// Physical target identity used to decide when retained pixels are reusable. +/// +/// `signature` is backend-defined and must change when pixel format, clear +/// color, sampling contract, or any other output-affecting configuration +/// changes without changing dimensions. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DamageTarget { + pub width: u32, + pub height: u32, + pub scale: u32, + pub signature: u64, +} + +impl DamageTarget { + pub const fn new(width: u32, height: u32, scale: u32, signature: u64) -> Self { + Self { + width, + height, + scale, + signature, + } + } +} + +/// Damage decision for one frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DamagePlan { + rects: [DamageRect; MAX_REGIONS], + len: usize, + full_redraw: bool, + screen: DamageRect, +} + +impl DamagePlan { + const fn empty(screen: DamageRect) -> Self { + Self { + rects: [DamageRect::empty(); MAX_REGIONS], + len: 0, + full_redraw: false, + screen, + } + } + + /// Construct a complete redraw plan for `screen`. + pub fn full(screen: DamageRect) -> Self { + let mut damage = Self::empty(screen); + if MAX_REGIONS > 0 && !screen.is_empty() { + damage.rects[0] = screen; + damage.len = 1; + } + damage.full_redraw = true; + damage + } + + /// Disjoint logical rectangles that must be repainted. + pub fn regions(&self) -> &[DamageRect] { + &self.rects[..self.len] + } + + pub fn region_count(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn area(&self) -> u64 { + self.regions().iter().map(|rect| rect.area()).sum() + } + + pub fn bounds(&self) -> DamageRect { + self.regions() + .iter() + .copied() + .fold(DamageRect::empty(), DamageRect::union) + } + + pub fn is_full_redraw(&self) -> bool { + self.full_redraw + } + + /// Apply a backend-selected full-redraw threshold. + pub fn with_policy(mut self, policy: DamagePolicy) -> Result { + if !policy.is_valid() { + return Err(DamageError::InvalidPolicy); + } + if !self.full_redraw && self.should_promote_to_full(self.screen, policy) { + self = Self::full(self.screen); + } + Ok(self) + } + + fn should_promote_to_full(&self, screen: DamageRect, policy: DamagePolicy) -> bool { + let screen_area = screen.area(); + !self.is_empty() + && screen_area > 0 + && self.area().saturating_mul(100) + >= screen_area.saturating_mul(policy.full_redraw_percent as u64) + } + + fn add(&mut self, rect: DamageRect, screen: DamageRect) { + let mut merged = rect.intersect(screen); + if merged.is_empty() { + return; + } + + let mut index = 0usize; + while index < self.len { + if merged.touches(self.rects[index]) { + merged = merged.union(self.rects[index]); + self.remove(index); + index = 0; + } else { + index += 1; + } + } + + if self.len < MAX_REGIONS { + self.rects[self.len] = merged; + self.len += 1; + return; + } + + let mut best = 0usize; + let mut best_inflation = u64::MAX; + for (candidate, &existing) in self.regions().iter().enumerate() { + let union = existing.union(merged); + let inflation = union + .area() + .saturating_sub(existing.area()) + .saturating_sub(merged.area()); + if inflation < best_inflation { + best = candidate; + best_inflation = inflation; + } + } + merged = merged.union(self.rects[best]); + self.remove(best); + self.add(merged, screen); + } + + fn remove(&mut self, index: usize) { + self.len -= 1; + self.rects[index] = self.rects[self.len]; + self.rects[self.len] = DamageRect::empty(); + } +} + +/// DrawList snapshot associated with one persistent render target. +pub struct DamageTracker { + words: Vec, + target: DamageTarget, + raster_revision: u64, + valid: bool, +} + +impl DamageTracker { + pub const fn new() -> Self { + Self { + words: Vec::new(), + target: DamageTarget::new(0, 0, 0, 0), + raster_revision: 0, + valid: false, + } + } + + /// Force the next frame to repaint the complete target. + pub fn invalidate(&mut self) { + self.valid = false; + } + + /// Compute damage without modifying the stored snapshot. + /// + /// Call [`commit`](Self::commit) only after the returned regions have + /// successfully been rendered. + pub fn prepare( + &self, + ui: &Ui, + words: &[u32], + target: DamageTarget, + ) -> Result, DamageError> { + if MAX_REGIONS == 0 { + return Err(DamageError::InvalidCapacity); + } + if target.scale == 0 { + return Err(DamageError::InvalidTarget); + } + let screen = target_screen(ui, target)?; + let full_redraw = + !self.valid || self.target != target || self.raster_revision != ui.raster_revision(); + let damage = if full_redraw { + validate_draw_list(ui, words, screen)?; + DamagePlan::full(screen) + } else { + draw_list_damage(ui, &self.words, words, screen)? + }; + Ok(damage) + } + + /// Record the DrawList after its [`DamagePlan`] has been rendered. + pub fn commit(&mut self, ui: &Ui, words: &[u32], target: DamageTarget) { + if self.words != words { + self.words.clear(); + self.words.extend_from_slice(words); + } + self.target = target; + self.raster_revision = ui.raster_revision(); + self.valid = true; + } +} + +impl Default for DamageTracker { + fn default() -> Self { + Self::new() + } +} + +fn target_screen(ui: &Ui, target: DamageTarget) -> Result { + let (viewport_width, viewport_height) = ui.viewport(); + if viewport_width <= 0.0 || viewport_height <= 0.0 { + return Err(DamageError::InvalidTarget); + } + let logical_width = viewport_width as u32; + let logical_height = viewport_height as u32; + if logical_width + .checked_mul(target.scale) + .ok_or(DamageError::InvalidTarget)? + != target.width + || logical_height + .checked_mul(target.scale) + .ok_or(DamageError::InvalidTarget)? + != target.height + { + return Err(DamageError::InvalidTarget); + } + Ok(DamageRect::new( + 0, + 0, + logical_width as i32, + logical_height as i32, + )) +} + +struct DecodedOp<'a> { + code: u32, + words: &'a [u32], + bounds: DamageRect, +} + +struct DamageDecoder<'a> { + words: &'a [u32], + index: usize, + screen: DamageRect, + clip: DamageRect, + stack: [DamageRect; CLIP_DEPTH], + depth: usize, +} + +impl<'a> DamageDecoder<'a> { + fn new(words: &'a [u32], screen: DamageRect) -> Self { + Self { + words, + index: 0, + screen, + clip: screen, + stack: [screen; CLIP_DEPTH], + depth: 0, + } + } + + fn next(&mut self, ui: &Ui) -> Result>, ()> { + if self.index == self.words.len() { + return Ok(None); + } + let start = self.index; + let code = *self.words.get(start).ok_or(())?; + let len = match code { + spec::draw_op::RECT => 4, + spec::draw_op::GRAD_RECT => 6, + spec::draw_op::GLYPH_RUN => { + let count = (self.words.get(start + 1).copied().ok_or(())? >> 16) as usize; + 3usize + .checked_add(count.checked_mul(2).ok_or(())?) + .ok_or(())? + } + spec::draw_op::TEX_QUAD => 9, + spec::draw_op::SCISSOR => 3, + spec::draw_op::SCISSOR_POP => 1, + spec::draw_op::TRI => 7, + spec::draw_op::TEX_TRI => 12, + _ => return Err(()), + }; + let end = start.checked_add(len).ok_or(())?; + let words = self.words.get(start..end).ok_or(())?; + self.index = end; + + let bounds = match code { + spec::draw_op::RECT | spec::draw_op::GRAD_RECT => { + logical_rect(words[1], words[2]).intersect(self.clip) + } + spec::draw_op::GLYPH_RUN => glyph_run_bounds(ui, words, self.clip), + spec::draw_op::TEX_QUAD => logical_rect(words[2], words[3]).intersect(self.clip), + spec::draw_op::SCISSOR => { + if self.depth >= self.stack.len() { + return Err(()); + } + self.stack[self.depth] = self.clip; + self.depth += 1; + self.clip = self.screen.intersect(logical_rect(words[1], words[2])); + self.clip + } + spec::draw_op::SCISSOR_POP => { + if self.depth == 0 { + return Err(()); + } + self.depth -= 1; + self.clip = self.stack[self.depth]; + DamageRect::empty() + } + spec::draw_op::TRI => triangle_bounds([words[1], words[2], words[3]], self.clip), + spec::draw_op::TEX_TRI => triangle_bounds([words[2], words[5], words[8]], self.clip), + _ => return Err(()), + }; + Ok(Some(DecodedOp { + code, + words, + bounds, + })) + } + + fn is_balanced(&self) -> bool { + self.depth == 0 + } +} + +fn draw_list_damage( + ui: &Ui, + previous: &[u32], + current: &[u32], + screen: DamageRect, +) -> Result, DamageError> { + if previous == current { + return Ok(DamagePlan::empty(screen)); + } + + let mut old = DamageDecoder::new(previous, screen); + let mut new = DamageDecoder::new(current, screen); + let mut damage = DamagePlan::empty(screen); + loop { + let old_op = old.next(ui).map_err(|_| DamageError::MalformedDrawList)?; + let new_op = new.next(ui).map_err(|_| DamageError::MalformedDrawList)?; + match (old_op, new_op) { + (None, None) => break, + (Some(old_op), Some(new_op)) if old_op.code == new_op.code => { + if old_op.words != new_op.words { + damage.add(old_op.bounds, screen); + damage.add(new_op.bounds, screen); + } + } + _ => return Ok(DamagePlan::full(screen)), + } + } + if !old.is_balanced() || !new.is_balanced() { + return Err(DamageError::MalformedDrawList); + } + Ok(damage) +} + +fn validate_draw_list(ui: &Ui, words: &[u32], screen: DamageRect) -> Result<(), DamageError> { + let mut decoder = DamageDecoder::new(words, screen); + while decoder + .next(ui) + .map_err(|_| DamageError::MalformedDrawList)? + .is_some() + {} + if decoder.is_balanced() { + Ok(()) + } else { + Err(DamageError::MalformedDrawList) + } +} + +fn glyph_run_bounds(ui: &Ui, words: &[u32], clip: DamageRect) -> DamageRect { + if words.len() < 3 || words[2] >> 24 == 0 { + return DamageRect::empty(); + } + let slot = (words[1] & 0xff) as u8; + let Some(atlas) = ui.font_atlas(slot) else { + return DamageRect::empty(); + }; + let mut bounds = DamageRect::empty(); + for glyph in words[3..].chunks_exact(2) { + let gid = (glyph[1] & 0xffff) as u16; + if gid >= atlas.glyph_count { + continue; + } + let (x, y) = xy(glyph[0]); + bounds = bounds.union(DamageRect::new( + x, + y, + x + atlas.cell_w as i32, + y + atlas.cell_h as i32, + )); + } + bounds.intersect(clip) +} + +fn triangle_bounds(vertices: [u32; 3], clip: DamageRect) -> DamageRect { + let [(x0, y0), (x1, y1), (x2, y2)] = vertices.map(xy); + DamageRect::new( + x0.min(x1).min(x2), + y0.min(y1).min(y2), + x0.max(x1).max(x2), + y0.max(y1).max(y2), + ) + .intersect(clip) +} + +#[inline] +fn xy(word: u32) -> (i32, i32) { + ( + (word & 0xffff) as u16 as i16 as i32, + (word >> 16) as u16 as i16 as i32, + ) +} + +#[inline] +fn wh(word: u32) -> (i32, i32) { + ((word & 0xffff) as i32, (word >> 16) as i32) +} + +#[inline] +fn logical_rect(xy_word: u32, wh_word: u32) -> DamageRect { + let (x, y) = xy(xy_word); + let (w, h) = wh(wh_word); + DamageRect::new(x, y, x + w, y + h) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn xy_word(x: i16, y: i16) -> u32 { + x as u16 as u32 | ((y as u16 as u32) << 16) + } + + fn wh_word(w: u16, h: u16) -> u32 { + w as u32 | ((h as u32) << 16) + } + + fn target(width: u32, height: u32, scale: u32) -> DamageTarget { + DamageTarget::new(width, height, scale, 1) + } + + fn frame(left: u32, right: u32) -> Vec { + vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(32, 16), + 0xff20_1008, + spec::draw_op::RECT, + xy_word(2, 4), + wh_word(4, 4), + left, + spec::draw_op::RECT, + xy_word(26, 4), + wh_word(4, 4), + right, + ] + } + + #[test] + fn first_unchanged_and_disjoint_frames_are_classified() { + let mut ui = Ui::new(); + ui.set_viewport(32.0, 16.0); + let previous = frame(0xff00_00ff, 0xff00_ff00); + let current = frame(0xffff_0000, 0xffff_ffff); + let mut tracker = DamageTracker::::new(); + + let first = tracker.prepare(&ui, &previous, target(32, 16, 1)).unwrap(); + assert!(first.is_full_redraw()); + assert_eq!(first.area(), 32 * 16); + tracker.commit(&ui, &previous, target(32, 16, 1)); + + let unchanged = tracker.prepare(&ui, &previous, target(32, 16, 1)).unwrap(); + assert!(!unchanged.is_full_redraw()); + assert!(unchanged.is_empty()); + + let changed = tracker.prepare(&ui, ¤t, target(32, 16, 1)).unwrap(); + assert!(!changed.is_full_redraw()); + assert_eq!(changed.region_count(), 2); + assert_eq!(changed.area(), 32); + } + + #[test] + fn structure_target_and_invalidation_force_full_redraws() { + let mut ui = Ui::new(); + ui.set_viewport(16.0, 8.0); + let previous = vec![ + spec::draw_op::RECT, + xy_word(1, 1), + wh_word(3, 3), + 0xff00_00ff, + ]; + let current = Vec::new(); + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(16, 8, 1)); + + let structural = tracker.prepare(&ui, ¤t, target(16, 8, 1)).unwrap(); + assert!(structural.is_full_redraw()); + + tracker.commit(&ui, ¤t, target(16, 8, 1)); + tracker.invalidate(); + assert!(tracker + .prepare(&ui, ¤t, target(16, 8, 1)) + .unwrap() + .is_full_redraw()); + + tracker.commit(&ui, ¤t, target(16, 8, 1)); + assert!(tracker + .prepare(&ui, ¤t, target(32, 16, 2)) + .unwrap() + .is_full_redraw()); + + tracker.commit(&ui, ¤t, target(32, 16, 2)); + assert!(tracker + .prepare(&ui, ¤t, DamageTarget::new(32, 16, 2, 2),) + .unwrap() + .is_full_redraw()); + } + + #[test] + fn policy_promotes_large_damage_and_rejects_invalid_configuration() { + let mut ui = Ui::new(); + ui.set_viewport(10.0, 10.0); + let previous = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(8, 10), + 0xff00_00ff, + ]; + let current = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(8, 10), + 0xff00_ff00, + ]; + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(10, 10, 1)); + let plan = tracker + .prepare(&ui, ¤t, target(10, 10, 1)) + .unwrap() + .with_policy(DamagePolicy::new(75)) + .unwrap(); + assert!(plan.is_full_redraw()); + + assert_eq!( + tracker + .prepare(&ui, ¤t, target(10, 10, 1)) + .unwrap() + .with_policy(DamagePolicy::new(0)), + Err(DamageError::InvalidPolicy) + ); + let empty_tracker = DamageTracker::<0>::new(); + assert_eq!( + empty_tracker.prepare(&ui, ¤t, target(10, 10, 1)), + Err(DamageError::InvalidCapacity) + ); + } + + #[test] + fn malformed_drawlists_are_rejected_and_prepare_does_not_commit() { + let mut ui = Ui::new(); + ui.set_viewport(16.0, 8.0); + let previous = vec![ + spec::draw_op::RECT, + xy_word(1, 1), + wh_word(3, 3), + 0xff00_00ff, + ]; + let current = vec![ + spec::draw_op::RECT, + xy_word(4, 1), + wh_word(3, 3), + 0xff00_ff00, + ]; + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(16, 8, 1)); + + let first_plan = tracker.prepare(&ui, ¤t, target(16, 8, 1)).unwrap(); + let second_plan = tracker.prepare(&ui, ¤t, target(16, 8, 1)).unwrap(); + assert_eq!(first_plan, second_plan); + + let fresh = DamageTracker::::new(); + assert_eq!( + fresh.prepare(&ui, &[spec::draw_op::RECT, xy_word(0, 0)], target(16, 8, 1),), + Err(DamageError::MalformedDrawList) + ); + assert_eq!( + fresh.prepare( + &ui, + &[spec::draw_op::SCISSOR, xy_word(0, 0), wh_word(4, 4),], + target(16, 8, 1), + ), + Err(DamageError::MalformedDrawList) + ); + } + + #[test] + fn capacity_merges_regions_and_fractional_viewports_match_raster_semantics() { + let mut ui = Ui::new(); + ui.set_viewport(30.0, 10.0); + let frame = |colors: [u32; 3]| { + let mut words = Vec::new(); + for (index, color) in colors.into_iter().enumerate() { + words.extend_from_slice(&[ + spec::draw_op::RECT, + xy_word((index * 10 + 1) as i16, 2), + wh_word(2, 2), + color, + ]); + } + words + }; + let previous = frame([0xff00_00ff, 0xff00_ff00, 0xffff_0000]); + let current = frame([0xffff_ffff, 0xff80_8080, 0xff00_0000]); + let mut tracker = DamageTracker::<2>::new(); + tracker.commit(&ui, &previous, target(30, 10, 1)); + let plan = tracker.prepare(&ui, ¤t, target(30, 10, 1)).unwrap(); + assert_eq!(plan.region_count(), 2); + assert!(plan.area() >= 12); + + ui.set_viewport(7.5, 5.5); + let fractional = DamageTracker::::new() + .prepare(&ui, &[], target(7, 5, 1)) + .unwrap(); + assert!(fractional.is_full_redraw()); + } + + #[test] + fn default_capacity_merges_a_ninth_disjoint_region() { + assert_eq!(DEFAULT_DAMAGE_REGIONS, 8); + let mut ui = Ui::new(); + ui.set_viewport(96.0, 8.0); + let frame = |color: u32| { + let mut words = Vec::new(); + for index in 0..9 { + words.extend_from_slice(&[ + spec::draw_op::RECT, + xy_word((index * 10 + 1) as i16, 2), + wh_word(2, 2), + color, + ]); + } + words + }; + let previous = frame(0xff00_00ff); + let current = frame(0xff00_ff00); + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &previous, target(96, 8, 1)); + + let plan = tracker.prepare(&ui, ¤t, target(96, 8, 1)).unwrap(); + assert!(!plan.is_full_redraw()); + assert_eq!(plan.region_count(), DEFAULT_DAMAGE_REGIONS); + assert!(plan.area() > 9 * 4); + } + + #[test] + fn core_resource_revision_invalidates_unchanged_drawlists() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 2.0); + let mut tracker = DamageTracker::::new(); + tracker.commit(&ui, &[], target(4, 2, 1)); + assert!(tracker + .prepare(&ui, &[], target(4, 2, 1)) + .unwrap() + .is_empty()); + + assert!(ui.upload_texture(&[0xff, 0xff, 0xff, 0xff], 1, 1, spec::psm::PSM_8888,) >= 0); + assert!(tracker + .prepare(&ui, &[], target(4, 2, 1)) + .unwrap() + .is_full_redraw()); + } +} diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index 88859aea..9b2414ed 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -32,6 +32,7 @@ use alloc::vec::Vec; pub mod anim; pub mod codec; +pub mod damage; pub mod draw; pub mod layout; pub mod package; @@ -224,6 +225,8 @@ pub struct Ui { /// coordinates always remain logical; only core-owned bitmap resources /// (currently rounded-corner masks) use this density. raster_density: u32, + /// Changes whenever raster-visible resource bytes or tables change. + raster_revision: u64, focused: i32, draw_list: DrawList, /// Virtual cursor sprite (spec ops 28/29, input.cursor capability): @@ -281,6 +284,7 @@ impl Ui { tex_free: Vec::new(), discs: draw::DiscCache::new(), raster_density, + raster_revision: 1, focused: 0, draw_list: DrawList::new(), cursor_tex: -1, @@ -301,6 +305,15 @@ impl Ui { self.raster_density } + /// Monotonic token for texture/font/style contents consumed by renderers. + pub fn raster_revision(&self) -> u64 { + self.raster_revision + } + + fn bump_raster_revision(&mut self) { + self.raster_revision = self.raster_revision.wrapping_add(1); + } + // ---- tree ops --------------------------------------------------------- /// Create a detached node of `node_type` (spec::NodeType value). @@ -513,7 +526,11 @@ impl Ui { palette, linear: flags & spec::img::FLAG_LINEAR != 0, }; - tex_alloc(&mut self.textures, &mut self.tex_free, tex) + let handle = tex_alloc(&mut self.textures, &mut self.tex_free, tex); + if handle >= 0 { + self.bump_raster_revision(); + } + handle } /// Upload a self-contained IMG pak entry (spec op uploadImgEntry; @@ -581,6 +598,7 @@ impl Ui { tex.byte_len, ); } + self.bump_raster_revision(); true } @@ -596,6 +614,7 @@ impl Ui { s.tex = None; s.gen = ((s.gen as u32 + 1) & TEX_GEN_MASK) as u16; self.tex_free.push(slot); + self.bump_raster_revision(); } /// Bind an uploaded texture to an image node. Handles are 0-based, so @@ -648,6 +667,7 @@ impl Ui { Some(t) => { self.styles = t; self.layout.dirty = true; + self.bump_raster_revision(); true } None => false, @@ -660,6 +680,7 @@ impl Ui { let ok = self.fonts.load(bytes); if ok { self.layout.dirty = true; + self.bump_raster_revision(); } ok } diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index f7f74a39..3fe1d4e4 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -37,10 +37,16 @@ //! reuse it (via [`render_scaled_rgb565_over`]) as their ordered software //! fallback for ops their accelerator cannot express. +use crate::damage::{ + DamageError, DamagePlan, DamagePolicy, DamageRect, DamageTarget, DamageTracker, +}; use crate::spec::{self, draw_op}; use crate::{TexView, Ui}; pub const MAX_RENDER_SCALE: u32 = 4; +const DAMAGE_SIGNATURE_RGBA8: u64 = u32::from_be_bytes(*b"RGBA") as u64; +const DAMAGE_SIGNATURE_ARGB8: u64 = u32::from_be_bytes(*b"ARGB") as u64; +const DAMAGE_SIGNATURE_RGB565: u64 = u32::from_be_bytes(*b"R565") as u64; /// Integer clip rect: x0/y0 inclusive, x1/y1 exclusive. #[derive(Clone, Copy)] @@ -307,6 +313,109 @@ pub fn render_scaled_rgb565_over(ui: &Ui, words: &[u32], fb: &mut [u16], scale: render_scaled_impl(ui, words, &mut target, scale, false); } +/// Clear and repaint only the supplied logical damage rectangles into an +/// existing RGBA8 framebuffer. +/// +/// Each region replays the complete DrawList under an additional root clip, +/// preserving painter order for unchanged translucent operations. +pub fn render_scaled_regions( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + scale: u32, + regions: &[DamageRect], +) { + let mut target = RgbaTarget:: { bytes: fb }; + render_scaled_regions_impl(ui, words, &mut target, scale, regions); +} + +/// ARGB/BGRA-memory equivalent of [`render_scaled_regions`]. +pub fn render_scaled_argb_regions( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + scale: u32, + regions: &[DamageRect], +) { + let mut target = RgbaTarget:: { bytes: fb }; + render_scaled_regions_impl(ui, words, &mut target, scale, regions); +} + +/// RGB565 equivalent of [`render_scaled_regions`]. +pub fn render_scaled_rgb565_regions( + ui: &Ui, + words: &[u32], + fb: &mut [u16], + scale: u32, + regions: &[DamageRect], +) { + let mut target = Rgb565Target { pixels: fb }; + render_scaled_regions_impl(ui, words, &mut target, scale, regions); +} + +/// Incrementally render RGBA8 using one tracker per persistent framebuffer. +pub fn render_scaled_incremental( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + scale: u32, + tracker: &mut DamageTracker, + policy: DamagePolicy, +) -> Result, DamageError> { + let mut target = RgbaTarget:: { bytes: fb }; + render_scaled_incremental_impl( + ui, + words, + &mut target, + scale, + tracker, + policy, + DAMAGE_SIGNATURE_RGBA8, + ) +} + +/// Incrementally render ARGB/BGRA-memory pixels. +pub fn render_scaled_argb_incremental( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + scale: u32, + tracker: &mut DamageTracker, + policy: DamagePolicy, +) -> Result, DamageError> { + let mut target = RgbaTarget:: { bytes: fb }; + render_scaled_incremental_impl( + ui, + words, + &mut target, + scale, + tracker, + policy, + DAMAGE_SIGNATURE_ARGB8, + ) +} + +/// Incrementally render native RGB565 pixels. +pub fn render_scaled_rgb565_incremental( + ui: &Ui, + words: &[u32], + fb: &mut [u16], + scale: u32, + tracker: &mut DamageTracker, + policy: DamagePolicy, +) -> Result, DamageError> { + let mut target = Rgb565Target { pixels: fb }; + render_scaled_incremental_impl( + ui, + words, + &mut target, + scale, + tracker, + policy, + DAMAGE_SIGNATURE_RGB565, + ) +} + fn render_scaled_impl( ui: &Ui, words: &[u32], @@ -314,6 +423,62 @@ fn render_scaled_impl( scale: u32, clear: bool, ) { + let (width, _height, screen) = target_geometry(ui, target, scale); + if clear { + target.clear_black(); + } + render_scaled_clipped(ui, words, target, width, scale as i32, screen); +} + +fn render_scaled_regions_impl( + ui: &Ui, + words: &[u32], + target: &mut T, + scale: u32, + regions: &[DamageRect], +) { + let (width, height, screen) = target_geometry(ui, target, scale); + render_damage_regions( + ui, + words, + target, + width, + height, + scale as i32, + screen, + regions, + ); +} + +fn render_scaled_incremental_impl( + ui: &Ui, + words: &[u32], + target: &mut T, + scale: u32, + tracker: &mut DamageTracker, + policy: DamagePolicy, + signature: u64, +) -> Result, DamageError> { + let (width, height, screen) = target_geometry(ui, target, scale); + let damage_target = DamageTarget::new(width as u32, height as u32, scale, signature); + let plan = tracker + .prepare(ui, words, damage_target)? + .with_policy(policy)?; + render_damage_regions( + ui, + words, + target, + width, + height, + scale as i32, + screen, + plan.regions(), + ); + tracker.commit(ui, words, damage_target); + Ok(plan) +} + +fn target_geometry(ui: &Ui, target: &T, scale: u32) -> (i32, i32, Clip) { assert!( (1..=MAX_RENDER_SCALE).contains(&scale), "render scale must be 1 through 4" @@ -338,10 +503,57 @@ fn render_scaled_impl( x1: width, y1: height, }; - if clear { - target.clear_black(); + (width, height, screen) +} + +#[allow(clippy::too_many_arguments)] +fn render_damage_regions( + ui: &Ui, + words: &[u32], + target: &mut T, + width: i32, + height: i32, + scale: i32, + screen: Clip, + regions: &[DamageRect], +) { + let logical_screen = DamageRect::new(0, 0, width / scale, height / scale); + for ®ion in regions { + let region = region.intersect(logical_screen); + if region.is_empty() { + continue; + } + let physical = Clip { + x0: region.x0 * scale, + y0: region.y0 * scale, + x1: region.x1 * scale, + y1: region.y1 * scale, + } + .intersect(screen); + if physical.x0 >= physical.x1 || physical.y0 >= physical.y1 { + continue; + } + clear_black_rect(target, width, physical); + render_scaled_clipped(ui, words, target, width, scale, physical); + } +} + +fn clear_black_rect(target: &mut T, stride: i32, rect: Clip) { + let row_pixels = (rect.x1 - rect.x0) as usize; + for y in rect.y0..rect.y1 { + let start = (y * stride + rect.x0) as usize; + target.fill_opaque(start, row_pixels, 0, 0, 0); } +} +fn render_scaled_clipped( + ui: &Ui, + words: &[u32], + target: &mut T, + width: i32, + scale: i32, + screen: Clip, +) { let mut stack: [Clip; 32] = [screen; 32]; let mut depth: usize = 0; let mut clip = screen; @@ -950,6 +1162,7 @@ fn tex_quad( #[cfg(test)] mod tests { use super::*; + use crate::damage::DEFAULT_DAMAGE_REGIONS; fn xy_word(x: i16, y: i16) -> u32 { x as u16 as u32 | ((y as u16 as u32) << 16) @@ -1109,6 +1322,357 @@ mod tests { ); } + #[test] + fn incremental_rgba_argb_and_rgb565_match_full_renders() { + let mut ui = Ui::new(); + ui.set_viewport(24.0, 12.0); + let frame = |moving_x: i16, moving_color: u32| { + vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(24, 12), + 0xff20_1008, + draw_op::RECT, + xy_word(moving_x, 2), + wh_word(8, 8), + moving_color, + // This unchanged translucent overlay intersects both the old + // and new moving rectangle and must be replayed in order. + draw_op::RECT, + xy_word(8, 4), + wh_word(8, 6), + 0x8000_ff00, + ] + }; + let previous = frame(2, 0x8000_00ff); + let current = frame(6, 0x80ff_0000); + let scale = 2; + let pixels = 24 * scale as usize * 12 * scale as usize; + + let mut rgba = vec![0u8; pixels * 4]; + let mut rgba_state = DamageTracker::::new(); + let first = render_scaled_incremental( + &ui, + &previous, + &mut rgba, + scale, + &mut rgba_state, + DamagePolicy::default(), + ) + .unwrap(); + assert!(first.is_full_redraw()); + let changed = render_scaled_incremental( + &ui, + ¤t, + &mut rgba, + scale, + &mut rgba_state, + DamagePolicy::default(), + ) + .unwrap(); + assert!(!changed.is_full_redraw()); + let mut rgba_full = vec![0u8; pixels * 4]; + render_scaled(&ui, ¤t, &mut rgba_full, scale); + assert_eq!(rgba, rgba_full); + let before = rgba.clone(); + let unchanged = render_scaled_incremental( + &ui, + ¤t, + &mut rgba, + scale, + &mut rgba_state, + DamagePolicy::default(), + ) + .unwrap(); + assert!(unchanged.is_empty()); + assert_eq!(rgba, before); + + let mut argb = vec![0u8; pixels * 4]; + let mut argb_state = DamageTracker::::new(); + render_scaled_argb_incremental( + &ui, + &previous, + &mut argb, + scale, + &mut argb_state, + DamagePolicy::default(), + ) + .unwrap(); + render_scaled_argb_incremental( + &ui, + ¤t, + &mut argb, + scale, + &mut argb_state, + DamagePolicy::default(), + ) + .unwrap(); + let mut argb_full = vec![0u8; pixels * 4]; + render_scaled_argb(&ui, ¤t, &mut argb_full, scale); + assert_eq!(argb, argb_full); + + let mut rgb565 = vec![0u16; pixels]; + let mut rgb565_state = DamageTracker::::new(); + render_scaled_rgb565_incremental( + &ui, + &previous, + &mut rgb565, + scale, + &mut rgb565_state, + DamagePolicy::default(), + ) + .unwrap(); + render_scaled_rgb565_incremental( + &ui, + ¤t, + &mut rgb565, + scale, + &mut rgb565_state, + DamagePolicy::default(), + ) + .unwrap(); + let mut rgb565_full = vec![0u16; pixels]; + render_scaled_rgb565(&ui, ¤t, &mut rgb565_full, scale); + assert_eq!(rgb565, rgb565_full); + + rgb565_state.invalidate(); + let invalidated = render_scaled_rgb565_incremental( + &ui, + ¤t, + &mut rgb565, + scale, + &mut rgb565_state, + DamagePolicy::default(), + ) + .unwrap(); + assert!(invalidated.is_full_redraw()); + assert_eq!(rgb565, rgb565_full); + } + + #[test] + fn incremental_software_raster_tracks_double_buffers_independently() { + let mut ui = Ui::new(); + ui.set_viewport(24.0, 8.0); + let frame = |x: i16, color: u32| { + vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(24, 8), + 0xff10_0804, + draw_op::RECT, + xy_word(x, 2), + wh_word(3, 3), + color, + ] + }; + let frames = [ + frame(1, 0xff00_00ff), + frame(5, 0xff00_ff00), + frame(9, 0xffff_0000), + frame(13, 0xffff_ffff), + ]; + let mut states = [ + DamageTracker::::new(), + DamageTracker::::new(), + ]; + let mut outputs = [vec![0u16; 24 * 8], vec![0u16; 24 * 8]]; + + for (index, words) in frames.iter().enumerate() { + let target = index & 1; + let plan = render_scaled_rgb565_incremental( + &ui, + words, + &mut outputs[target], + 1, + &mut states[target], + DamagePolicy::default(), + ) + .unwrap(); + assert_eq!(plan.is_full_redraw(), index < 2); + + let mut expected = vec![0u16; 24 * 8]; + render_scaled_rgb565(&ui, words, &mut expected, 1); + assert_eq!(outputs[target], expected); + } + } + + #[test] + fn incremental_default_capacity_merges_nine_regions_without_pixel_regression() { + assert_eq!(DEFAULT_DAMAGE_REGIONS, 8); + let mut ui = Ui::new(); + ui.set_viewport(96.0, 8.0); + let frame = |color: u32| { + let mut words = vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(96, 8), + 0xff10_0804, + ]; + for index in 0..9 { + words.extend_from_slice(&[ + draw_op::RECT, + xy_word((index * 10 + 1) as i16, 2), + wh_word(2, 2), + color, + ]); + } + words + }; + let previous = frame(0xff00_00ff); + let current = frame(0xff00_ff00); + let mut incremental = vec![0u16; 96 * 8]; + let mut tracker = DamageTracker::::new(); + render_scaled_rgb565_incremental( + &ui, + &previous, + &mut incremental, + 1, + &mut tracker, + DamagePolicy::default(), + ) + .unwrap(); + let plan = render_scaled_rgb565_incremental( + &ui, + ¤t, + &mut incremental, + 1, + &mut tracker, + DamagePolicy::default(), + ) + .unwrap(); + assert!(!plan.is_full_redraw()); + assert_eq!(plan.region_count(), DEFAULT_DAMAGE_REGIONS); + + let mut full = vec![0u16; incremental.len()]; + render_scaled_rgb565(&ui, ¤t, &mut full, 1); + assert_eq!(incremental, full); + } + + #[test] + fn incremental_target_signature_prevents_cross_format_reuse() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 2.0); + let words = [draw_op::RECT, xy_word(0, 0), wh_word(4, 2), 0xff33_2211]; + let mut bytes = vec![0u8; 4 * 2 * 4]; + let mut state = DamageTracker::::new(); + render_scaled_incremental( + &ui, + &words, + &mut bytes, + 1, + &mut state, + DamagePolicy::default(), + ) + .unwrap(); + + let argb = render_scaled_argb_incremental( + &ui, + &words, + &mut bytes, + 1, + &mut state, + DamagePolicy::default(), + ) + .unwrap(); + assert!(argb.is_full_redraw()); + assert!(bytes + .chunks_exact(4) + .all(|pixel| pixel == [0x33, 0x22, 0x11, 0xff])); + } + + #[test] + fn incremental_complex_ops_match_a_full_render() { + let mut ui = Ui::new_with_raster_density(2); + ui.set_viewport(40.0, 24.0); + let texture_pixels = (0..16) + .flat_map(|value| [(value * 16) as u8, 32, 192, 255]) + .collect::>(); + let texture = ui.upload_texture(&texture_pixels, 4, 4, spec::psm::PSM_8888); + assert!(texture >= 0); + assert!(ui.load_font_atlas(&density_two_font())); + + let frame = |offset: i16, tint: u32| { + vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(40, 24), + 0xff18_1008, + draw_op::SCISSOR, + xy_word(1, 1), + wh_word(12, 9), + draw_op::GRAD_RECT, + xy_word(2 + offset, 2), + wh_word(7, 5), + tint, + 0xffff_ffff, + spec::GradDir::ToRight as u32, + draw_op::SCISSOR_POP, + draw_op::TEX_QUAD, + texture as u32, + xy_word(15 + offset, 2), + wh_word(5, 5), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + tint, + draw_op::TRI, + xy_word(23 + offset, 2), + xy_word(29 + offset, 8), + xy_word(22 + offset, 8), + tint, + 0xff00_ff00, + 0xffff_0000, + draw_op::TEX_TRI, + texture as u32, + xy_word(3 + offset, 13), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + xy_word(9 + offset, 19), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + xy_word(2 + offset, 19), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + tint, + draw_op::GLYPH_RUN, + 1 << 16, + tint, + xy_word(15 + offset, 13), + 0, + ] + }; + let previous = frame(0, 0xff00_00ff); + let current = frame(2, 0xc0ff_8000); + let scale = 2; + let mut incremental = vec![0u16; 40 * scale as usize * 24 * scale as usize]; + let mut tracker = DamageTracker::::new(); + render_scaled_rgb565_incremental( + &ui, + &previous, + &mut incremental, + scale, + &mut tracker, + DamagePolicy::new(100), + ) + .unwrap(); + let plan = render_scaled_rgb565_incremental( + &ui, + ¤t, + &mut incremental, + scale, + &mut tracker, + DamagePolicy::new(100), + ) + .unwrap(); + assert!(!plan.is_full_redraw()); + assert!(!plan.is_empty()); + + let mut full = vec![0u16; incremental.len()]; + render_scaled_rgb565(&ui, ¤t, &mut full, scale); + assert_eq!(incremental, full); + } + #[test] fn psm5650_textures_decode_into_native_rgb565() { let mut ui = Ui::new(); From 88961a00bf2e468d1a871548982a148995ddf8a2 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Fri, 24 Jul 2026 14:34:37 +0800 Subject: [PATCH 2/4] feat(esp32p4): render PPA frames incrementally --- engine/backends/esp32p4-ppa/README.md | 23 + engine/backends/esp32p4-ppa/src/lib.rs | 641 ++++++++++++++++++++++--- 2 files changed, 588 insertions(+), 76 deletions(-) diff --git a/engine/backends/esp32p4-ppa/README.md b/engine/backends/esp32p4-ppa/README.md index 405e367e..0f9874db 100644 --- a/engine/backends/esp32p4-ppa/README.md +++ b/engine/backends/esp32p4-ppa/README.md @@ -24,6 +24,29 @@ Gradients, arbitrary triangles, textured triangles, and unsupported texture formats fall back to `pocketjs_core::raster::render_scaled_rgb565_over`. No full-frame RGB888 or ARGB8888 surface is allocated. +## Incremental rendering + +`Renderer::render_incremental` uses the backend-independent +`pocketjs_core::damage::DamageTracker` to compare the current DrawList with +the DrawList that produced a persistent RGB565 target. Changed operation +bounds are collected into up to 8 disjoint logical damage rectangles. Each +rectangle is cleared and the current DrawList is replayed through that +rectangle's scissor, preserving normal painter order and translucent +composition without touching unchanged pixels. + +Keep one `RenderTargetState` per framebuffer. This is required for +double-buffered hosts because each target contains a different older frame. +The first render and structural DrawList changes use a conservative full +redraw. This backend additionally promotes damage covering at least 75 +percent of the viewport; that transaction-cost policy is deliberately kept +outside the common damage planner. + +Core-managed texture, font, and style mutations bump `Ui::raster_revision()`, +so every `RenderTargetState` automatically forces a complete repaint and the +renderer drops texture classifications even when DrawList handles stay +unchanged. Call `Renderer::invalidate_resources()` and explicitly invalidate +target states only for output-affecting changes performed outside `Ui`. + ## Test Run the portable renderer and pixel-parity tests on the host: diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs index bf3b05d5..a242ff7f 100644 --- a/engine/backends/esp32p4-ppa/src/lib.rs +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -12,6 +12,10 @@ extern crate alloc; use alloc::vec::Vec; +use pocketjs_core::damage::{ + DamagePlan, DamagePolicy, DamageRect as Clip, DamageTarget, DamageTracker, + DEFAULT_DAMAGE_REGIONS, +}; use pocketjs_core::raster::{ coverage_index, linear_sample_coordinates, pack_rgb565, render_scaled_rgb565_over, }; @@ -25,6 +29,9 @@ pub use esp_idf::EspIdfPpaOps; const MASK_ALIGNMENT: usize = 128; const CLIP_DEPTH: usize = 32; const TEXTURE_CLASS_CACHE_LEN: usize = 64; +const MAX_DAMAGE_REGIONS: usize = DEFAULT_DAMAGE_REGIONS; +const FULL_REDRAW_PERCENT: u8 = 75; +const DAMAGE_TARGET_SIGNATURE: u64 = u32::from_be_bytes(*b"PPA5") as u64; /// Integer pixel rectangle, using half-open bounds. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -140,8 +147,19 @@ pub struct RenderStats { pub ppa_srm: u32, pub software_ops: u32, pub software_words: u32, + /// Number of disjoint physical framebuffer regions repainted. + pub damage_regions: u32, + /// Total physical pixels repainted across all damage regions. + pub damage_pixels: u32, + /// Bounding rectangle of every repainted physical region. + pub damage_bounds: Rect, + /// True for an initial, invalidated, or heuristically promoted full frame. + pub full_redraw: bool, } +/// Core damage snapshot describing the pixels stored in one framebuffer. +pub type RenderTargetState = DamageTracker; + /// Persistent DrawList renderer. The A8 plane and fallback word buffer are /// reused across frames. pub struct Renderer { @@ -151,6 +169,7 @@ pub struct Renderer { mask_len: usize, fallback_words: Vec, alpha_texture_cache: Vec<(i32, bool)>, + raster_revision: u64, } impl Renderer { @@ -165,6 +184,7 @@ impl Renderer { mask_len: 0, fallback_words: Vec::with_capacity(16), alpha_texture_cache: Vec::new(), + raster_revision: 0, }) } @@ -172,6 +192,21 @@ impl Renderer { self.config } + /// Drop classifications derived from texture bytes after resources are + /// changed in place. Framebuffer states must be invalidated separately. + pub fn invalidate_resources(&mut self) { + self.alpha_texture_cache.clear(); + self.raster_revision = 0; + } + + fn sync_resources(&mut self, ui: &Ui) { + let revision = ui.raster_revision(); + if self.raster_revision != revision { + self.alpha_texture_cache.clear(); + self.raster_revision = revision; + } + } + /// Render a complete DrawList. `destination` dimensions must equal the /// UI viewport multiplied by `config.scale`. pub fn render( @@ -183,6 +218,60 @@ impl Renderer { height: u32, ppa: &mut O, ) -> Option { + self.sync_resources(ui); + let screen = self.target_screen(ui, destination, width, height)?; + let damage = DamagePlan::::full(screen); + self.render_damage(ui, words, destination, width, height, &damage, true, ppa) + } + + /// Repaint only pixels whose DrawList operations differ from the snapshot + /// stored in `target`. + /// + /// The destination must retain the pixels produced by the same + /// `RenderTargetState`; double-buffered hosts therefore keep one state per + /// buffer. Structural DrawList changes, invalidated resources, and damage + /// covering most of the screen conservatively fall back to a full redraw. + #[allow(clippy::too_many_arguments)] + pub fn render_incremental( + &mut self, + target: &mut RenderTargetState, + ui: &Ui, + words: &[u32], + destination: &mut [u16], + width: u32, + height: u32, + ppa: &mut O, + ) -> Option { + self.sync_resources(ui); + self.target_screen(ui, destination, width, height)?; + let damage_target = + DamageTarget::new(width, height, self.config.scale, DAMAGE_TARGET_SIGNATURE); + let damage = target + .prepare(ui, words, damage_target) + .ok()? + .with_policy(DamagePolicy::new(FULL_REDRAW_PERCENT)) + .ok()?; + let full_redraw = damage.is_full_redraw(); + + let result = self.render_damage( + ui, + words, + destination, + width, + height, + &damage, + full_redraw, + ppa, + ); + if result.is_some() { + target.commit(ui, words, damage_target); + } else { + target.invalidate(); + } + result + } + + fn target_screen(&self, ui: &Ui, destination: &[u16], width: u32, height: u32) -> Option { let scale = self.config.scale; let (viewport_w, viewport_h) = ui.viewport(); if viewport_w as u32 * scale != width @@ -191,27 +280,78 @@ impl Renderer { { return None; } - self.ensure_mask(destination.len()); - let screen = Clip { + Some(Clip { x0: 0, y0: 0, x1: viewport_w as i32, y1: viewport_h as i32, - }; - let mut stats = RenderStats::default(); + }) + } - let full = Rect { - x: 0, - y: 0, - w: width, - h: height, + #[allow(clippy::too_many_arguments)] + fn render_damage( + &mut self, + ui: &Ui, + words: &[u32], + destination: &mut [u16], + width: u32, + height: u32, + damage: &DamagePlan, + full_redraw: bool, + ppa: &mut O, + ) -> Option { + let scale = self.config.scale; + let mut stats = RenderStats { + damage_regions: damage.region_count() as u32, + damage_pixels: damage + .area() + .saturating_mul(scale as u64) + .saturating_mul(scale as u64) + .min(u32::MAX as u64) as u32, + damage_bounds: physical_rect(damage.bounds(), scale), + full_redraw, + ..RenderStats::default() }; - if ppa.fill_rgb565(destination, width, height, full, 0) { - stats.ppa_fills += 1; - } else { - destination.fill(0); + if damage.is_empty() { + return Some(stats); } + self.ensure_mask(destination.len()); + for ®ion in damage.regions() { + let physical = physical_rect(region, scale); + if physical.area() >= self.config.min_fill_pixels + && ppa.fill_rgb565(destination, width, height, physical, 0) + { + stats.ppa_fills += 1; + } else { + fill_rgb565_rect(destination, width, physical, 0); + } + self.render_region( + ui, + words, + destination, + width, + height, + region, + ppa, + &mut stats, + )?; + } + Some(stats) + } + + #[allow(clippy::too_many_arguments)] + fn render_region( + &mut self, + ui: &Ui, + words: &[u32], + destination: &mut [u16], + width: u32, + height: u32, + screen: Clip, + ppa: &mut O, + stats: &mut RenderStats, + ) -> Option<()> { let mut stack = [screen; CLIP_DEPTH]; let mut depth = 0usize; let mut clip = screen; @@ -222,24 +362,27 @@ impl Renderer { let rect = logical_rect(words[i + 1], words[i + 2]).intersect(clip); let op = &words[i..i + 4]; if !rect.is_empty() - && self.try_rect( + && !self.try_rect( destination, width, height, rect, words[i + 3], ppa, - &mut stats, + stats, ) { - i += 4; - } else { - self.software_op(ui, destination, clip, op, &mut stats); - i += 4; + self.software_op(ui, destination, clip, op, stats); } + i += 4; } spec::draw_op::GRAD_RECT if i + 6 <= words.len() => { - self.software_op(ui, destination, clip, &words[i..i + 6], &mut stats); + if !logical_rect(words[i + 1], words[i + 2]) + .intersect(clip) + .is_empty() + { + self.software_op(ui, destination, clip, &words[i..i + 6], stats); + } i += 6; } spec::draw_op::GLYPH_RUN if i + 3 <= words.len() => { @@ -248,21 +391,22 @@ impl Renderer { if next > words.len() { return None; } - if !self.try_glyph_run( - ui, - destination, - width, - height, - clip, - &words[i..next], - ppa, - &mut stats, - ) { - self.software_op(ui, destination, clip, &words[i..next], &mut stats); + let op = &words[i..next]; + if !glyph_run_bounds(ui, op, clip).is_empty() + && !self.try_glyph_run(ui, destination, width, height, clip, op, ppa, stats) + { + self.software_op(ui, destination, clip, op, stats); } i = next; } spec::draw_op::TEX_QUAD if i + 9 <= words.len() => { + if logical_rect(words[i + 2], words[i + 3]) + .intersect(clip) + .is_empty() + { + i += 9; + continue; + } let next = self.try_tex_quad_run( ui, words, @@ -272,12 +416,12 @@ impl Renderer { height, clip, ppa, - &mut stats, + stats, ); if let Some(next) = next { i = next; } else { - self.software_op(ui, destination, clip, &words[i..i + 9], &mut stats); + self.software_op(ui, destination, clip, &words[i..i + 9], stats); i += 9; } } @@ -300,17 +444,23 @@ impl Renderer { i += 1; } spec::draw_op::TRI if i + 7 <= words.len() => { - self.software_op(ui, destination, clip, &words[i..i + 7], &mut stats); + if !triangle_bounds([words[i + 1], words[i + 2], words[i + 3]], clip).is_empty() + { + self.software_op(ui, destination, clip, &words[i..i + 7], stats); + } i += 7; } spec::draw_op::TEX_TRI if i + 12 <= words.len() => { - self.software_op(ui, destination, clip, &words[i..i + 12], &mut stats); + if !triangle_bounds([words[i + 2], words[i + 5], words[i + 8]], clip).is_empty() + { + self.software_op(ui, destination, clip, &words[i..i + 12], stats); + } i += 12; } _ => return None, } } - Some(stats) + Some(()) } fn try_rect( @@ -606,51 +756,40 @@ impl Renderer { } } -#[derive(Clone, Copy, Debug)] -struct Clip { - x0: i32, - y0: i32, - x1: i32, - y1: i32, -} - -impl Clip { - const fn empty() -> Self { - Self { - x0: i32::MAX, - y0: i32::MAX, - x1: i32::MIN, - y1: i32::MIN, - } - } - - fn intersect(self, other: Self) -> Self { - Self { - x0: self.x0.max(other.x0), - y0: self.y0.max(other.y0), - x1: self.x1.min(other.x1), - y1: self.y1.min(other.y1), - } +fn glyph_run_bounds(ui: &Ui, words: &[u32], clip: Clip) -> Clip { + if words.len() < 3 || words[2] >> 24 == 0 { + return Clip::empty(); } - - fn union(self, other: Self) -> Self { - if self.is_empty() { - return other; - } - if other.is_empty() { - return self; - } - Self { - x0: self.x0.min(other.x0), - y0: self.y0.min(other.y0), - x1: self.x1.max(other.x1), - y1: self.y1.max(other.y1), + let slot = (words[1] & 0xff) as u8; + let Some(atlas) = ui.font_atlas(slot) else { + return Clip::empty(); + }; + let mut bounds = Clip::empty(); + for glyph in words[3..].chunks_exact(2) { + let gid = (glyph[1] & 0xffff) as u16; + if gid >= atlas.glyph_count { + continue; } + let (x, y) = xy(glyph[0]); + bounds = bounds.union(Clip { + x0: x, + y0: y, + x1: x + atlas.cell_w as i32, + y1: y + atlas.cell_h as i32, + }); } + bounds.intersect(clip) +} - fn is_empty(self) -> bool { - self.x0 >= self.x1 || self.y0 >= self.y1 +fn triangle_bounds(vertices: [u32; 3], clip: Clip) -> Clip { + let [(x0, y0), (x1, y1), (x2, y2)] = vertices.map(xy); + Clip { + x0: x0.min(x1).min(x2), + y0: y0.min(y1).min(y2), + x1: x0.max(x1).max(x2), + y1: y0.max(y1).max(y2), } + .intersect(clip) } #[inline] @@ -691,6 +830,13 @@ fn physical_rect(clip: Clip, scale: u32) -> Rect { } } +fn fill_rgb565_rect(destination: &mut [u16], stride: u32, rect: Rect, color: u16) { + for y in rect.y..rect.y + rect.h { + let start = y as usize * stride as usize + rect.x as usize; + destination[start..start + rect.w as usize].fill(color); + } +} + #[inline] fn channels(color: u32) -> (u32, u32, u32, u32) { ( @@ -1041,6 +1187,349 @@ mod tests { .unwrap() } + #[test] + fn core_resource_revision_invalidates_texture_classifications() { + let mut ui = Ui::new(); + let mut renderer = renderer(); + renderer.sync_resources(&ui); + renderer.alpha_texture_cache.push((7, true)); + + assert_eq!( + ui.upload_texture(&[0xff, 0xff], 1, 1, spec::psm::PSM_5650), + 0 + ); + renderer.sync_resources(&ui); + assert!(renderer.alpha_texture_cache.is_empty()); + assert_eq!(renderer.raster_revision, ui.raster_revision()); + } + + fn full_reference(ui: &Ui, words: &[u32], width: usize, height: usize) -> Vec { + let mut output = vec![0u16; width * height]; + pocketjs_core::raster::render_scaled_rgb565(ui, words, &mut output, 1); + output + } + + #[test] + fn incremental_render_skips_an_unchanged_target() { + let mut ui = Ui::new(); + ui.set_viewport(16.0, 8.0); + let words = [ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(16, 8), + 0xff20_1008, + spec::draw_op::RECT, + xy_word(2, 2), + wh_word(4, 3), + 0xff00_00ff, + ]; + let mut output = vec![0u16; 16 * 8]; + let mut state = RenderTargetState::new(); + let mut renderer = renderer(); + let first = renderer + .render_incremental( + &mut state, + &ui, + &words, + &mut output, + 16, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + assert!(first.full_redraw); + assert_eq!(first.damage_regions, 1); + assert_eq!(first.damage_pixels, 16 * 8); + + let before = output.clone(); + let mut ppa = MockPpa::default(); + let second = renderer + .render_incremental(&mut state, &ui, &words, &mut output, 16, 8, &mut ppa) + .unwrap(); + assert!(!second.full_redraw); + assert_eq!(second.damage_regions, 0); + assert_eq!(second.damage_pixels, 0); + assert_eq!(ppa.fills + ppa.blends + ppa.srm, 0); + assert_eq!(output, before); + } + + #[test] + fn incremental_render_keeps_disjoint_damage_regions() { + let mut ui = Ui::new(); + ui.set_viewport(32.0, 16.0); + let frame = |left: u32, right: u32| { + vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(32, 16), + 0xff20_1008, + spec::draw_op::RECT, + xy_word(2, 4), + wh_word(4, 4), + left, + spec::draw_op::RECT, + xy_word(26, 4), + wh_word(4, 4), + right, + spec::draw_op::GRAD_RECT, + xy_word(13, 10), + wh_word(6, 3), + 0xff00_0000, + 0xffff_ffff, + spec::GradDir::ToRight as u32, + ] + }; + let previous = frame(0xff00_00ff, 0xff00_ff00); + let current = frame(0xffff_0000, 0xffff_ffff); + let mut output = vec![0u16; 32 * 16]; + let mut state = RenderTargetState::new(); + let mut renderer = renderer(); + renderer + .render_incremental( + &mut state, + &ui, + &previous, + &mut output, + 32, + 16, + &mut MockPpa::default(), + ) + .unwrap(); + + let stats = renderer + .render_incremental( + &mut state, + &ui, + ¤t, + &mut output, + 32, + 16, + &mut MockPpa::default(), + ) + .unwrap(); + assert!(!stats.full_redraw); + assert_eq!(stats.damage_regions, 2); + assert_eq!(stats.damage_pixels, 32); + assert_eq!(stats.software_ops, 0, "unchanged off-damage gradient"); + assert_eq!(output, full_reference(&ui, ¤t, 32, 16)); + } + + #[test] + fn incremental_render_merges_a_ninth_region_at_default_capacity() { + assert_eq!(MAX_DAMAGE_REGIONS, 8); + let mut ui = Ui::new(); + ui.set_viewport(96.0, 8.0); + let frame = |color: u32| { + let mut words = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(96, 8), + 0xff10_0804, + ]; + for index in 0..9 { + words.extend_from_slice(&[ + spec::draw_op::RECT, + xy_word((index * 10 + 1) as i16, 2), + wh_word(2, 2), + color, + ]); + } + words + }; + let previous = frame(0xff00_00ff); + let current = frame(0xff00_ff00); + let mut output = vec![0u16; 96 * 8]; + let mut state = RenderTargetState::new(); + let mut renderer = renderer(); + renderer + .render_incremental( + &mut state, + &ui, + &previous, + &mut output, + 96, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + + let stats = renderer + .render_incremental( + &mut state, + &ui, + ¤t, + &mut output, + 96, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + assert!(!stats.full_redraw); + assert_eq!(stats.damage_regions, MAX_DAMAGE_REGIONS as u32); + assert_eq!(output, full_reference(&ui, ¤t, 96, 8)); + } + + #[test] + fn incremental_render_replays_unchanged_overlays_in_painter_order() { + let mut ui = Ui::new(); + ui.set_viewport(24.0, 12.0); + let frame = |moving_x: i16, moving_color: u32| { + vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(24, 12), + 0xff20_1008, + spec::draw_op::RECT, + xy_word(moving_x, 2), + wh_word(8, 8), + moving_color, + spec::draw_op::RECT, + xy_word(8, 4), + wh_word(8, 6), + 0x8000_ff00, + ] + }; + let previous = frame(2, 0x8000_00ff); + let current = frame(6, 0x80ff_0000); + let mut output = vec![0u16; 24 * 12]; + let mut state = RenderTargetState::new(); + let mut renderer = renderer(); + renderer + .render_incremental( + &mut state, + &ui, + &previous, + &mut output, + 24, + 12, + &mut MockPpa::default(), + ) + .unwrap(); + + let stats = renderer + .render_incremental( + &mut state, + &ui, + ¤t, + &mut output, + 24, + 12, + &mut MockPpa::default(), + ) + .unwrap(); + assert!(!stats.full_redraw); + assert_eq!(output, full_reference(&ui, ¤t, 24, 12)); + } + + #[test] + fn incremental_render_tracks_each_double_buffer_independently() { + let mut ui = Ui::new(); + ui.set_viewport(24.0, 8.0); + let frame = |x: i16, color: u32| { + vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(24, 8), + 0xff10_0804, + spec::draw_op::RECT, + xy_word(x, 2), + wh_word(3, 3), + color, + ] + }; + let frames = [ + frame(1, 0xff00_00ff), + frame(5, 0xff00_ff00), + frame(9, 0xffff_0000), + frame(13, 0xffff_ffff), + ]; + let mut renderer = renderer(); + let mut states = [RenderTargetState::new(), RenderTargetState::new()]; + let mut outputs = [vec![0u16; 24 * 8], vec![0u16; 24 * 8]]; + + for (index, words) in frames.iter().enumerate() { + let target = index & 1; + let stats = renderer + .render_incremental( + &mut states[target], + &ui, + words, + &mut outputs[target], + 24, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + assert_eq!(stats.full_redraw, index < 2); + assert_eq!(outputs[target], full_reference(&ui, words, 24, 8)); + } + } + + #[test] + fn incremental_render_falls_back_for_structural_changes_and_invalidation() { + let mut ui = Ui::new(); + ui.set_viewport(16.0, 8.0); + let previous = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(16, 8), + 0xff10_0804, + spec::draw_op::RECT, + xy_word(2, 2), + wh_word(3, 3), + 0xff00_00ff, + ]; + let current = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(16, 8), + 0xff10_0804, + ]; + let mut renderer = renderer(); + let mut state = RenderTargetState::new(); + let mut output = vec![0u16; 16 * 8]; + renderer + .render_incremental( + &mut state, + &ui, + &previous, + &mut output, + 16, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + + let structural = renderer + .render_incremental( + &mut state, + &ui, + ¤t, + &mut output, + 16, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + assert!(structural.full_redraw); + assert_eq!(output, full_reference(&ui, ¤t, 16, 8)); + + state.invalidate(); + let invalidated = renderer + .render_incremental( + &mut state, + &ui, + ¤t, + &mut output, + 16, + 8, + &mut MockPpa::default(), + ) + .unwrap(); + assert!(invalidated.full_redraw); + assert_eq!(invalidated.damage_pixels, 16 * 8); + } + #[test] fn accelerates_fills_and_keeps_gradient_fallback_in_order() { let mut ui = Ui::new(); From 5e565991758878f0bbb60560b4c0a56abc86132c Mon Sep 17 00:00:00 2001 From: halfsweet Date: Fri, 24 Jul 2026 14:34:54 +0800 Subject: [PATCH 3/4] feat(web): enable incremental wasm rendering --- engine/wasm/src/lib.rs | 66 +++++++++++++++++++++++++++++++++++++++-- hosts/web/engine.js | 2 +- hosts/web/wasm-ops.d.ts | 4 +++ hosts/web/wasm-ops.js | 18 ++++++++++- site/playground/host.js | 2 +- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/engine/wasm/src/lib.rs b/engine/wasm/src/lib.rs index 2a6e3832..adaa2e58 100644 --- a/engine/wasm/src/lib.rs +++ b/engine/wasm/src/lib.rs @@ -11,9 +11,10 @@ //! - Strings/buffers cross via linear memory: the host calls //! `ui_alloc(len)`, writes bytes at the returned offset, passes //! (ptr, len), then `ui_free(ptr, len)`. UTF-8 for text. -//! - `ui_render[_scaled]()` returns the framebuffer pointer: RGBA8, tightly -//! packed, row-major, top-left origin. The pointer stays valid until the -//! next render or init call on this instance. +//! - `ui_render[_scaled]()` performs a compatibility full render. +//! `ui_render_incremental[_scaled]()` retains the same RGBA8 framebuffer +//! and repaints only changed regions. The returned pointer stays valid +//! until the next render or init call on this instance. //! - Single-threaded by construction (one wasm instance per Ui). #![allow(static_mut_refs)] @@ -22,12 +23,14 @@ // safety IS the ABI contract (ui_alloc/ui_free above), not a Rust signature. #![allow(clippy::not_unsafe_ptr_arg_deref)] +use pocketjs_core::damage::{DamagePolicy, DamageTracker, DEFAULT_DAMAGE_REGIONS}; use pocketjs_core::Ui; use pocketjs_core::raster; static mut UI: Option = None; static mut FRAMEBUFFER: Vec = Vec::new(); +static mut DAMAGE_TRACKER: DamageTracker = DamageTracker::new(); #[inline] fn ui() -> &'static mut Ui { @@ -60,6 +63,7 @@ pub extern "C" fn ui_init(raster_density: u32) { unsafe { UI = Some(Ui::new_with_raster_density(raster_density.max(1))); FRAMEBUFFER.clear(); + DAMAGE_TRACKER = DamageTracker::new(); } } @@ -308,6 +312,50 @@ fn render_at_scale(scale: u32) -> *const u8 { } else { raster::render_scaled(u_ref, &(*dl).words, &mut FRAMEBUFFER, scale); } + // A compatibility full render changed the retained framebuffer + // outside the incremental tracker's plan/commit transaction. + DAMAGE_TRACKER.invalidate(); + FRAMEBUFFER.as_ptr() + } +} + +fn render_incremental_at_scale(scale: u32) -> *const u8 { + if !(1..=raster::MAX_RENDER_SCALE).contains(&scale) { + return core::ptr::null(); + } + let u = ui(); + let (viewport_w, viewport_h) = u.viewport(); + let logical_width = viewport_w as usize; + let logical_height = viewport_h as usize; + let Some(width) = logical_width.checked_mul(scale as usize) else { + return core::ptr::null(); + }; + let Some(height) = logical_height.checked_mul(scale as usize) else { + return core::ptr::null(); + }; + let Some(bytes) = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(4)) + else { + return core::ptr::null(); + }; + let dl: *const pocketjs_core::DrawList = u.draw(); + let u_ref: &Ui = unsafe { &*(u as *const Ui) }; + unsafe { + FRAMEBUFFER.resize(bytes, 0); + if raster::render_scaled_incremental( + u_ref, + &(*dl).words, + &mut FRAMEBUFFER, + scale, + &mut DAMAGE_TRACKER, + DamagePolicy::default(), + ) + .is_err() + { + raster::render_scaled(u_ref, &(*dl).words, &mut FRAMEBUFFER, scale); + DAMAGE_TRACKER.invalidate(); + } FRAMEBUFFER.as_ptr() } } @@ -327,6 +375,18 @@ pub extern "C" fn ui_render_scaled(scale: u32) -> *const u8 { render_at_scale(scale) } +/// Incrementally rasterize at the logical viewport size. +#[no_mangle] +pub extern "C" fn ui_render_incremental() -> *const u8 { + render_incremental_at_scale(1) +} + +/// Incrementally rasterize at an integer physical scale (1 through 4). +#[no_mangle] +pub extern "C" fn ui_render_incremental_scaled(scale: u32) -> *const u8 { + render_incremental_at_scale(scale) +} + #[cfg(test)] mod tests { use super::draw_hash; diff --git a/hosts/web/engine.js b/hosts/web/engine.js index 72945887..4c0559bb 100644 --- a/hosts/web/engine.js +++ b/hosts/web/engine.js @@ -223,7 +223,7 @@ function safeFrame() { function blit() { if (!wasm || !ctx) return; - const pixels = wasm.renderScaled(RENDER_SCALE); + const pixels = wasm.renderScaledIncremental(RENDER_SCALE); imageData.data.set(pixels); ctx.putImageData(imageData, 0, 0); drawHud(ctx, FB_W, FB_H, hudFps, hudMem); // built-in on-canvas overlay diff --git a/hosts/web/wasm-ops.d.ts b/hosts/web/wasm-ops.d.ts index af6fdd4f..9bc17604 100644 --- a/hosts/web/wasm-ops.d.ts +++ b/hosts/web/wasm-ops.d.ts @@ -19,6 +19,10 @@ export interface WasmUi { render(): Uint8Array; /** Rasterize directly at an integer physical scale from 1 through 4. */ renderScaled(scale: number): Uint8Array; + /** Repaint only changed regions at the logical viewport size. */ + renderIncremental(): Uint8Array; + /** Repaint only changed regions at an integer physical scale. */ + renderScaledIncremental(scale: number): Uint8Array; } export declare function createWasmUi( diff --git a/hosts/web/wasm-ops.js b/hosts/web/wasm-ops.js index c0e0c9f6..da3bf20a 100644 --- a/hosts/web/wasm-ops.js +++ b/hosts/web/wasm-ops.js @@ -17,7 +17,8 @@ const encoder = new TextEncoder(); /** * Instantiate pocketjs.wasm and return - * { ops, init, tick, drawHash, render, renderScaled, exports }. + * { ops, init, tick, drawHash, render, renderScaled, + * renderIncremental, renderScaledIncremental, exports }. * `ops` is a complete HostOps (framework/src/host.ts) — hand it to the app bundle as * globalThis.ui before eval'ing it. * @@ -148,6 +149,21 @@ export async function createWasmUi(wasm, options = {}) { scale = integerInRange(scale, "render scale", 1, 4); return framebufferView(ex.ui_render_scaled(scale), scale); }, + /** Repaint only changed regions, falling back for older wasm builds. */ + renderIncremental() { + const ptr = ex.ui_render_incremental + ? ex.ui_render_incremental() + : ex.ui_render(); + return framebufferView(ptr, 1); + }, + /** Incremental equivalent of renderScaled(). */ + renderScaledIncremental(scale) { + scale = integerInRange(scale, "render scale", 1, 4); + const ptr = ex.ui_render_incremental_scaled + ? ex.ui_render_incremental_scaled(scale) + : ex.ui_render_scaled(scale); + return framebufferView(ptr, scale); + }, }; } diff --git a/site/playground/host.js b/site/playground/host.js index 93a95770..445ff295 100644 --- a/site/playground/host.js +++ b/site/playground/host.js @@ -307,7 +307,7 @@ export class PocketHost { _blit() { if (!this.wasm || !this.ctx) return; - this.imageData.data.set(this.wasm.render()); + this.imageData.data.set(this.wasm.renderIncremental()); this.ctx.putImageData(this.imageData, 0, 0); if (this.showHud) { drawHud(this.ctx, FB_W, FB_H, this._hudFps, this._hudMem); // built-in on-canvas overlay From c4d55cd52851b6973c3b04a3b5eb92141dff1db3 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Fri, 24 Jul 2026 14:35:08 +0800 Subject: [PATCH 4/4] docs: describe incremental rendering architecture --- docs/DESIGN.md | 12 ++++++++++-- docs/STRUCTURE.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d864cbe3..7f20a3d0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -60,6 +60,12 @@ artifacts: `$JOB_TMP/map-*.json`). PSP bin wraps it with QuickJS + sceGu; `wasm32-unknown-unknown` build wraps it with a deterministic software rasterizer used by BOTH the browser dev host and headless Bun goldens. One layout engine everywhere. +- **Damage is backend-independent**: `engine/core/src/damage.rs` compares + retained DrawLists and returns logical damage regions for one persistent + framebuffer. RGBA8, PPA-memory ARGB8 and RGB565 software paths clear each + region and replay the complete DrawList under a root clip; framebuffer + format/scale signatures and `Ui::raster_revision()` prevent stale reuse. + Region limits and full-redraw thresholds remain backend policy. - **ESP32-P4 stays 16-bit**: `engine/backends/esp32p4-ppa/` consumes the same DrawList into an opaque RGB565 target. It maps flat fills, A8 coverage blending, and compatible PSM 5650 texture transforms to the PPA, then @@ -128,9 +134,11 @@ PocketJS/ handles are exposed as ui.__textures / ui.__sprites [R] engine/wasm/ Rust cdylib `pocketjs-wasm` — core + rasterizer, no wasm-bindgen framework/src/lib.rs extern "C" op mirror + byte-exact render() at 480×272; - renderScaled(1..4) rasterizes directly at physical density + renderScaled(1..4) rasterizes directly at physical density; + opt-in incremental exports retain and repaint damage regions + engine/core/src/damage.rs DrawList diff, logical damage plans and per-target snapshots engine/core/src/raster.rs shared deterministic scanline rasterizer used by wasm and - native capture (blend, gradients, glyphs, textures) + native capture (full and region-clipped RGBA/ARGB/RGB565) framework/src/ TS/JS runtime shared by all hosts renderer.ts Solid universal createRenderer; JS mirror tree; setProperty DISPATCH TABLE [R]: class→styleId, on*→input registry, diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 7dffc47b..23914cae 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -8,7 +8,7 @@ directory; nothing else gets a top-level name. ``` pocketjs/ ├─ engine/ Cores: the Rust simulation cores -│ ├─ core/ pocketjs-core — retained UI tree, taffy layout, raster (standalone crate) +│ ├─ core/ pocketjs-core — retained UI tree, taffy layout, damage + raster (standalone crate) │ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std crate) │ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate) │ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples