diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 8257bb5c..c485b9f7 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -354,6 +354,10 @@ export const PROP = { // center = node center, outer radius = min(w,h)/2, color // = bgColor. Axis-aligned worlds only (rotation belongs // in arcStart). + rasterCache: 143, // enum RasterCache. Retained asks capable hosts to cache + // this subtree as a transparent raster layer and apply + // translation during composition. Flat draw() output is + // unchanged; hosts opt in through Ui.draw_retained(). } as const; export type PropName = keyof typeof PROP; @@ -485,6 +489,7 @@ export const PROP_VALUE_KIND: Record = { rotateX: VALUE_KIND.f32, rotateY: VALUE_KIND.f32, translateZ: VALUE_KIND.f32, perspective: VALUE_KIND.f32, arcStart: VALUE_KIND.f32, arcSweep: VALUE_KIND.f32, arcWidth: VALUE_KIND.f32, + rasterCache: VALUE_KIND.int, }; // --------------------------------------------------------------------------- @@ -502,6 +507,7 @@ export const ENUMS = { TextAlign: { Left: 0, Center: 1, Right: 2 }, /** Gradient direction: `bg-gradient-to-t|b|l|r`. */ GradDir: { ToTop: 0, ToBottom: 1, ToLeft: 2, ToRight: 3 }, + RasterCache: { None: 0, Retained: 1 }, /** * Animation easing. Spring/SpringBouncy ignore durMs (physics decide); * OutBack overshoots ~10%. All tick at fixed dt = 1/60 s. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6bbae072..16795bc2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -96,7 +96,8 @@ PocketJS/ pak container constants (magic/header/entry/align/fnv1a) [R] gen-rust.ts codegen → engine/core/src/spec.rs (committed) engine/core/ Rust lib `pocketjs-core` — #![no_std] + alloc - framework/src/lib.rs pub struct Ui: apply-ops, tick(1/60), draw() → &DrawList + framework/src/lib.rs pub struct Ui: apply-ops, tick(1/60), draw() → &DrawList; + draw_retained() → ordered Draw/Layer passes for capable hosts framework/src/spec.rs GENERATED — tests/contract.ts re-runs gen-rust.ts and byte-compares this file (airtight drift guard) [R] framework/src/tree.rs node arena: Vec + free list + GENERATION COUNTER diff --git a/docs/RETAINED_LAYERS.md b/docs/RETAINED_LAYERS.md new file mode 100644 index 00000000..13824c6c --- /dev/null +++ b/docs/RETAINED_LAYERS.md @@ -0,0 +1,63 @@ +# Retained raster layers + +Status: accepted and implemented. + +## Context + +`Ui::draw()` deliberately produces one flat DrawList. That contract is simple +and deterministic, but a host cannot reuse subtree pixels when only the +subtree's screen translation changes. Re-rasterizing text during scrolling is +especially expensive on small no-JIT systems. + +## Decision + +Property id 143 is the append-only `rasterCache` integer property. A value of +`RasterCache.Retained` asks a capable host to cache that subtree as a +transparent raster surface. It is a hint: ordinary `Ui::draw()` stays flat and +pixel-compatible. + +Capable hosts opt in with `Ui::draw_retained()`. It returns ordered +`RetainedPass` values: + +- `Draw` contains regular content before or after a layer; +- `Layer` contains a layer-local DrawList, logical surface dimensions, screen + translation, and the inherited screen clip. + +Hosts must composite every pass in order. A layer's DrawList remains unchanged +when only its translation changes, so the host can keep pixels and update the +composition coordinates. Layer content changes are compatible with the normal +per-target `DamageTracker`; explicit-size ARGB surface raster functions avoid +pretending that the layer is a viewport-sized framebuffer. + +The core currently retains translation-only 2D layer roots. Scale, rotation, +or perspective on a requested root falls back to the surrounding regular +DrawList. This preserves content until a host can implement those transforms +without forcing re-rasterization. + +## Consequences + +- The flat DrawList ABI and existing hosts do not change behavior. +- A retained host owns surface allocation, double buffering, damage trackers, + and composition scheduling. +- Ordered regular passes can be transparent and sparse. `draw_list_coverage()` + exposes conservative disjoint rectangles so a compositor can skip guaranteed + transparent space without changing pixels. +- Retaining many or large subtrees can consume substantial memory; the hint is + intentionally explicit rather than automatic. + +## Rejected alternatives + +- Moving a rectangle after rendering the flat frame does not avoid subtree + raster work and cannot recover painter-order ownership. +- Making all transformed nodes implicit layers makes memory and scheduling + costs unpredictable. +- Teaching the application to draw text through a platform-specific side path + duplicates layout and font semantics outside PocketJS. + +## Verification + +- Core tests compare flat raster output with CPU composition of translated and + clipped retained passes (maximum channel delta: one, from blend rounding). +- Translation-only tests assert that layer-local words remain stable while the + composition coordinates change. +- Damage coverage tests verify conservative, disjoint painted regions. diff --git a/engine/core/src/damage.rs b/engine/core/src/damage.rs index c6f839b8..efd589af 100644 --- a/engine/core/src/damage.rs +++ b/engine/core/src/damage.rs @@ -314,6 +314,48 @@ impl DamageTracker { ui: &Ui, words: &[u32], target: DamageTarget, + ) -> Result, DamageError> { + let screen = target_screen(ui, target)?; + self.prepare_for_screen(ui, words, target, screen) + } + + /// Compute damage for a persistent surface whose logical dimensions are + /// independent of the UI viewport, such as a retained raster layer. + pub fn prepare_surface( + &self, + ui: &Ui, + words: &[u32], + target: DamageTarget, + logical_width: u32, + logical_height: u32, + ) -> Result, DamageError> { + if logical_width == 0 + || logical_height == 0 + || 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); + } + self.prepare_for_screen( + ui, + words, + target, + DamageRect::new(0, 0, logical_width as i32, logical_height as i32), + ) + } + + fn prepare_for_screen( + &self, + ui: &Ui, + words: &[u32], + target: DamageTarget, + screen: DamageRect, ) -> Result, DamageError> { if MAX_REGIONS == 0 { return Err(DamageError::InvalidCapacity); @@ -321,7 +363,6 @@ impl DamageTracker { 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 { @@ -516,6 +557,42 @@ fn validate_draw_list(ui: &Ui, words: &[u32], screen: DamageRect) -> Result<(), } } +/// Return conservative disjoint coverage rectangles for a DrawList on an +/// explicitly sized logical surface. Backends can use these rectangles to +/// composite transparent regular passes without blending the whole target. +pub fn draw_list_coverage( + ui: &Ui, + words: &[u32], + logical_width: u32, + logical_height: u32, +) -> Result, DamageError> { + if MAX_REGIONS == 0 { + return Err(DamageError::InvalidCapacity); + } + if logical_width == 0 + || logical_height == 0 + || logical_width > i32::MAX as u32 + || logical_height > i32::MAX as u32 + { + return Err(DamageError::InvalidTarget); + } + let screen = DamageRect::new(0, 0, logical_width as i32, logical_height as i32); + let mut decoder = DamageDecoder::new(words, screen); + let mut coverage = DamagePlan::empty(screen); + while let Some(op) = decoder + .next(ui) + .map_err(|_| DamageError::MalformedDrawList)? + { + if op.code != spec::draw_op::SCISSOR && op.code != spec::draw_op::SCISSOR_POP { + coverage.add(op.bounds, screen); + } + } + if !decoder.is_balanced() { + return Err(DamageError::MalformedDrawList); + } + Ok(coverage) +} + fn glyph_run_bounds(ui: &Ui, words: &[u32], clip: DamageRect) -> DamageRect { if words.len() < 3 || words[2] >> 24 == 0 { return DamageRect::empty(); @@ -628,6 +705,31 @@ mod tests { assert_eq!(changed.area(), 32); } + #[test] + fn coverage_ignores_scissors_and_keeps_disjoint_painted_regions() { + let mut ui = Ui::new(); + ui.set_viewport(40.0, 20.0); + let words = vec![ + spec::draw_op::SCISSOR, + xy_word(2, 1), + wh_word(36, 18), + spec::draw_op::RECT, + xy_word(3, 2), + wh_word(4, 5), + 0xffff_ffff, + spec::draw_op::RECT, + xy_word(30, 12), + wh_word(5, 4), + 0xffff_ffff, + spec::draw_op::SCISSOR_POP, + ]; + let coverage = draw_list_coverage::(&ui, &words, 40, 20) + .expect("valid coverage"); + assert_eq!(coverage.region_count(), 2); + assert_eq!(coverage.area(), 40); + assert_eq!(coverage.bounds(), DamageRect::new(3, 2, 35, 16)); + } + #[test] fn structure_target_and_invalidation_force_full_redraws() { let mut ui = Ui::new(); diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs index a38d08c3..89322267 100644 --- a/engine/core/src/draw.rs +++ b/engine/core/src/draw.rs @@ -22,6 +22,7 @@ use alloc::vec::Vec; +use crate::damage::DamageRect; use crate::layout::{floorf, roundf}; use crate::spec; use crate::style::{self, StyleTable, NO_GRADIENT}; @@ -32,6 +33,7 @@ use crate::tree::Tree; /// Format pinned in contracts/spec/spec.ts ("DRAWLIST op format"); op codes in /// spec::draw_op. On wasm the host reads this as a Uint32Array; on PSP, /// hosts/psp/src/ge.rs walks it into sceGu calls. +#[derive(Clone, Debug, PartialEq, Eq)] pub struct DrawList { pub words: Vec, } @@ -48,6 +50,37 @@ impl Default for DrawList { } } +/// One subtree isolated by [`Ui::draw_retained`](crate::Ui::draw_retained). +/// +/// `draw` is expressed in layer-local coordinates and does not change when +/// an otherwise unchanged layer is translated. Capable hosts retain its +/// transparent pixels and composite them at (`x`, `y`) under `clip`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RetainedLayer { + pub node_id: i32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub clip: DamageRect, + pub draw: DrawList, +} + +/// Ordered output of a retained draw. Regular passes and cached layers must +/// be composited in sequence to preserve z-order. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RetainedPass { + Draw(DrawList), + Layer(RetainedLayer), +} + +/// Host-facing retained frame. Ordinary [`Ui::draw`](crate::Ui::draw) keeps +/// producing the original single flat DrawList for backwards compatibility. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RetainedFrame { + pub passes: Vec, +} + // ---- small math (no_std: no libm, no micromath — local polyfills) ----------- const PI: f32 = core::f32::consts::PI; @@ -803,6 +836,100 @@ struct Walker<'a> { inspect_hit: Option, } +trait PaintSink { + fn retains_layers(&self) -> bool; + fn draw_list(&mut self) -> &mut DrawList; + + fn push_scissor(&mut self, clip: Clip) { + let draw = self.draw_list(); + draw.words.push(spec::draw_op::SCISSOR); + draw.words.push(xy_word(clip.x0, clip.y0)); + draw.words.push(wh_word(clip.x1 - clip.x0, clip.y1 - clip.y0)); + } + + fn pop_scissor(&mut self) { + self.draw_list().words.push(spec::draw_op::SCISSOR_POP); + } + + fn push_layer(&mut self, _layer: RetainedLayer) {} +} + +struct FlatSink<'a> { + draw: &'a mut DrawList, +} + +impl PaintSink for FlatSink<'_> { + fn retains_layers(&self) -> bool { + false + } + + fn draw_list(&mut self) -> &mut DrawList { + self.draw + } +} + +struct RetainedSink<'a> { + frame: &'a mut RetainedFrame, + clips: Vec, +} + +impl RetainedSink<'_> { + fn new(frame: &mut RetainedFrame) -> RetainedSink<'_> { + frame.passes.clear(); + frame.passes.push(RetainedPass::Draw(DrawList::new())); + RetainedSink { + frame, + clips: Vec::new(), + } + } + + fn current_draw(&mut self) -> &mut DrawList { + match self.frame.passes.last_mut() { + Some(RetainedPass::Draw(draw)) => draw, + _ => unreachable!("retained frame always ends in a regular draw pass"), + } + } +} + +impl PaintSink for RetainedSink<'_> { + fn retains_layers(&self) -> bool { + true + } + + fn draw_list(&mut self) -> &mut DrawList { + self.current_draw() + } + + fn push_scissor(&mut self, clip: Clip) { + let draw = self.current_draw(); + draw.words.push(spec::draw_op::SCISSOR); + draw.words.push(xy_word(clip.x0, clip.y0)); + draw.words.push(wh_word(clip.x1 - clip.x0, clip.y1 - clip.y0)); + self.clips.push(clip); + } + + fn pop_scissor(&mut self) { + self.current_draw().words.push(spec::draw_op::SCISSOR_POP); + self.clips.pop(); + } + + fn push_layer(&mut self, layer: RetainedLayer) { + let depth = self.clips.len(); + for _ in 0..depth { + self.current_draw().words.push(spec::draw_op::SCISSOR_POP); + } + self.frame.passes.push(RetainedPass::Layer(layer)); + let mut next = DrawList::new(); + for &clip in &self.clips { + next.words.push(spec::draw_op::SCISSOR); + next.words.push(xy_word(clip.x0, clip.y0)); + next.words + .push(wh_word(clip.x1 - clip.x0, clip.y1 - clip.y0)); + } + self.frame.passes.push(RetainedPass::Draw(next)); + } +} + /// Build the full DrawList for the current (laid-out) tree. `frame` is the /// core's vblank counter (Ui.frame); animated sprites pick their cell from it. /// `screen` is the viewport every coordinate is clipped to. `cursor` is the @@ -825,6 +952,77 @@ pub fn build( cursor: Option<(u32, f32, f32, f32, f32)>, ) -> (Option<(f32, f32, f32, f32)>, Option<(f32, f32, f32, f32)>) { dl.words.clear(); + let mut sink = FlatSink { draw: dl }; + build_with_sink( + tree, + styles, + fonts, + frame, + screen, + textures, + tex_free, + discs, + raster_density, + &mut sink, + inspect_id, + inspect_prev, + cursor, + ) +} + +/// Build ordered regular/layer passes for hosts with transparent retained +/// composition. Unsupported layer transforms fall back into the surrounding +/// regular pass, so opting in never drops content. +#[allow(clippy::too_many_arguments)] +pub fn build_retained( + tree: &Tree, + styles: &StyleTable, + fonts: &Fonts, + frame: u64, + screen: (f32, f32), + textures: &mut Vec, + tex_free: &mut Vec, + discs: &mut DiscCache, + raster_density: u32, + retained: &mut RetainedFrame, + inspect_id: i32, + inspect_prev: Option<(f32, f32, f32, f32)>, + cursor: Option<(u32, f32, f32, f32, f32)>, +) -> (Option<(f32, f32, f32, f32)>, Option<(f32, f32, f32, f32)>) { + let mut sink = RetainedSink::new(retained); + build_with_sink( + tree, + styles, + fonts, + frame, + screen, + textures, + tex_free, + discs, + raster_density, + &mut sink, + inspect_id, + inspect_prev, + cursor, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_with_sink( + tree: &Tree, + styles: &StyleTable, + fonts: &Fonts, + frame: u64, + screen: (f32, f32), + textures: &mut Vec, + tex_free: &mut Vec, + discs: &mut DiscCache, + raster_density: u32, + sink: &mut S, + inspect_id: i32, + inspect_prev: Option<(f32, f32, f32, f32)>, + cursor: Option<(u32, f32, f32, f32, f32)>, +) -> (Option<(f32, f32, f32, f32)>, Option<(f32, f32, f32, f32)>) { // DevTools (docs/DEVTOOLS.md): slot of the inspected node, u32::MAX = none. // Nodes inside a perspective subtree take the paint_3d path and are not // captured (only the 2D walk composes a world Affine per node). @@ -848,7 +1046,7 @@ pub fn build( inspect_hit: None, }; let root_slot = crate::tree::split_id(spec::ROOT_ID).1; - w.paint(root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), dl); + w.paint(root_slot, Affine::IDENTITY, 1.0, Clip::viewport(screen), sink); let target = w.inspect_hit.map(|c| (c.x0, c.y0, c.x1 - c.x0, c.y1 - c.y0)); // Highlight glide: the drawn box exponentially approaches the target // (~0.35/draw ≈ converged in 6 draws), so switching the inspected node @@ -874,13 +1072,13 @@ pub fn build( (None, _) => None, }; if let Some((x, y, bw, bh)) = drawn { - w.emit_highlight(dl, &Clip { x0: x, y0: y, x1: x + bw, y1: y + bh }); + w.emit_highlight(sink.draw_list(), &Clip { x0: x, y0: y, x1: x + bw, y1: y + bh }); } // Virtual cursor sprite: appended last so nothing paints over it (even // the DevTools highlight sits under the pointer the user is steering). if let Some((tex, cx, cy, cw, ch)) = cursor { w.emit_tex_quad( - dl, + sink.draw_list(), &Affine::translate(cx, cy), cw, ch, @@ -897,7 +1095,14 @@ pub fn build( } impl<'a> Walker<'a> { - fn paint(&mut self, slot: u32, parent_world: Affine, opacity: f32, clip: Clip, dl: &mut DrawList) { + fn paint( + &mut self, + slot: u32, + parent_world: Affine, + opacity: f32, + clip: Clip, + sink: &mut S, + ) { let node = &self.tree.slots[slot as usize]; let r = style::resolve(node, self.styles, true); if r.display == spec::Display::None as u8 { @@ -915,25 +1120,77 @@ impl<'a> Walker<'a> { return; } - // -- background + shadow -------------------------------------------- - let has_grad = r.grad_dir != NO_GRADIENT && r.grad_dir <= spec::GradDir::ToRight as u32; - let bg_color = scale_alpha(r.bg_color, op); - let border_color = scale_alpha(r.border_color, op); - let rounded_border = r.radius > 0.0 && r.border_width > 0.0 && alpha(border_color) > 0; - let rounded_ring = rounded_border && (has_grad || alpha(bg_color) > 0); - - if r.shadow > 0 && (alpha(bg_color) > 0 || has_grad) { - self.emit_shadow(dl, &world, l.w, l.h, r.radius, r.shadow, op, &clip); + // Retained raster caches deliberately support translation-only layer + // roots. Scaling/rotation/perspective still paint into the regular + // DrawList, preserving flat behavior until a host can composite those + // transforms without changing pixels. + let translation_only = world.a == 1.0 && world.b == 0.0 && world.c == 0.0 && world.d == 1.0; + if sink.retains_layers() + && r.raster_cache == spec::RasterCache::Retained as u8 + && translation_only + && r.perspective <= 0.0 + && l.w > 0.0 + && l.h > 0.0 + { + let width = roundf(l.w).max(1.0) as u32; + let height = roundf(l.h).max(1.0) as u32; + let x = roundf(world.tx) as i32; + let y = roundf(world.ty) as i32; + let local_parent = Affine::translate(-world.tx, -world.ty).then(&parent_world); + let local_clip = Clip { + x0: 0.0, + y0: 0.0, + x1: width as f32, + y1: height as f32, + }; + let previous_screen = self.screen; + self.screen = (width as f32, height as f32); + let mut draw = DrawList::new(); + { + let mut flat = FlatSink { draw: &mut draw }; + self.paint(slot, local_parent, opacity, local_clip, &mut flat); + } + self.screen = previous_screen; + sink.push_layer(RetainedLayer { + node_id: node.id(slot), + x, + y, + width, + height, + clip: DamageRect::new( + roundf(clip.x0) as i32, + roundf(clip.y0) as i32, + roundf(clip.x1) as i32, + roundf(clip.y1) as i32, + ), + draw, + }); + return; } - let is_arc = r.arc_width > 0.0 && r.arc_sweep != 0.0; - if is_arc { + { + let dl = sink.draw_list(); + // -- background + shadow ---------------------------------------- + let has_grad = + r.grad_dir != NO_GRADIENT && r.grad_dir <= spec::GradDir::ToRight as u32; + let bg_color = scale_alpha(r.bg_color, op); + let border_color = scale_alpha(r.border_color, op); + let rounded_border = + r.radius > 0.0 && r.border_width > 0.0 && alpha(border_color) > 0; + let rounded_ring = rounded_border && (has_grad || alpha(bg_color) > 0); + + if r.shadow > 0 && (alpha(bg_color) > 0 || has_grad) { + self.emit_shadow(dl, &world, l.w, l.h, r.radius, r.shadow, op, &clip); + } + + let is_arc = r.arc_width > 0.0 && r.arc_sweep != 0.0; + if is_arc { // Arc primitive: the bg color strokes an annular sector instead // of filling the box (spec.ts PROP.arcStart/arcSweep/arcWidth). if alpha(bg_color) > 0 { self.emit_arc(dl, &world, l.w, l.h, &r, bg_color, &clip); } - } else if rounded_ring { + } else if rounded_ring { self.emit_rounded_box(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, Fill::Flat(border_color), &clip); let bw = r.border_width.min(l.w * 0.5).min(l.h * 0.5); if has_grad { @@ -956,20 +1213,20 @@ impl<'a> Walker<'a> { &clip, ); } - } else if has_grad { + } else if has_grad { let fill = Fill::Grad { from: scale_alpha(r.grad_from, op), to: scale_alpha(r.grad_to, op), dir: r.grad_dir, }; self.emit_rounded_box(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, fill, &clip); - } else if alpha(bg_color) > 0 { + } else if alpha(bg_color) > 0 { self.emit_rounded_box(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, Fill::Flat(bg_color), &clip); - } + } - // -- border: 4 inset strips ------------------------------------------ - let bw = r.border_width; - if !rounded_ring && bw > 0.0 && alpha(border_color) > 0 { + // -- border: 4 inset strips -------------------------------------- + let bw = r.border_width; + if !rounded_ring && bw > 0.0 && alpha(border_color) > 0 { if rounded_border { self.emit_rounded_border(dl, &world, 0.0, 0.0, l.w, l.h, r.radius, bw, Fill::Flat(border_color), &clip); } else { @@ -981,19 +1238,22 @@ impl<'a> Walker<'a> { self.emit_box(dl, &world, 0.0, bwy, bwx, l.h - bwy, bc, &clip); // left self.emit_box(dl, &world, l.w - bwx, bwy, l.w, l.h - bwy, bc, &clip); // right } - } + } // -- bevel rings: classic-chrome 3D edges (spec.ts PROP.bevelOuter*..) -- // Two nested inset rings of 4 strips each. Per ring, light paints // top+left first, then dark paints bottom+right FULL-LENGTH, so dark // owns the shared corners (98.css box-shadow stacking). Square only: // radius > 0 disables bevels (the compiler rejects the combination). - if (r.bevel_outer_light | r.bevel_outer_dark | r.bevel_inner_light | r.bevel_inner_dark) + if (r.bevel_outer_light + | r.bevel_outer_dark + | r.bevel_inner_light + | r.bevel_inner_dark) != 0 && r.bevel_width > 0.0 && r.radius <= 0.0 && !is_arc - { + { let bvw = r.bevel_width; let rings = [ (0.0, r.bevel_outer_light, r.bevel_outer_dark), @@ -1021,17 +1281,17 @@ impl<'a> Walker<'a> { self.emit_box(dl, &world, x1 - bvw, y0, x1, y1, fd, &clip); // right } } - } + } - // -- text run ---------------------------------------------------------- - if node.node_type == spec::NodeType::Text as u8 { - self.emit_text(dl, node, &r, &world, op, &clip, l.w); - // Text children are absorbed into the run — do not recurse. - return; - } + // -- text run ---------------------------------------------------- + if node.node_type == spec::NodeType::Text as u8 { + self.emit_text(dl, node, &r, &world, op, &clip, l.w); + // Text children are absorbed into the run — do not recurse. + return; + } - // -- image / animated sprite ------------------------------------------- - if node.node_type == spec::NodeType::Image as u8 && node.tex >= 0 { + // -- image / animated sprite ------------------------------------- + if node.node_type == spec::NodeType::Image as u8 && node.tex >= 0 { // Plain image samples the whole texture; a sprite samples the // current frame's atlas cell (auto-played from the vblank counter). let (fu0, fv0, fu1, fv1) = if node.sprite_frames > 0 { @@ -1050,7 +1310,20 @@ impl<'a> Walker<'a> { } else { (0.0, 0.0, 1.0, 1.0) }; - self.emit_tex_quad(dl, &world, l.w, l.h, node.tex as u32, op, &clip, fu0, fv0, fu1, fv1); + self.emit_tex_quad( + dl, + &world, + l.w, + l.h, + node.tex as u32, + op, + &clip, + fu0, + fv0, + fu1, + fv1, + ); + } } // -- children (overflow-hidden scissor around them; z-index stable @@ -1065,18 +1338,25 @@ impl<'a> Walker<'a> { if child_clip.is_empty() { return; // nothing of the subtree can be visible } - dl.words.push(spec::draw_op::SCISSOR); - dl.words.push(xy_word(child_clip.x0, child_clip.y0)); - dl.words.push(wh_word(child_clip.x1 - child_clip.x0, child_clip.y1 - child_clip.y0)); + sink.push_scissor(child_clip); scissored = true; } if r.perspective > 0.0 { // 3D context root: the subtree composes 3x4 matrices, projects // through r.perspective about this node's center and painter-sorts. - self.paint_3d(slot, &world, op, &child_clip, dl, r.perspective, l.w, l.h); + self.paint_3d( + slot, + &world, + op, + &child_clip, + sink.draw_list(), + r.perspective, + l.w, + l.h, + ); if scissored { - dl.words.push(spec::draw_op::SCISSOR_POP); + sink.pop_scissor(); } return; } @@ -1085,11 +1365,11 @@ impl<'a> Walker<'a> { // never disagree with painted stacking. let (tree, styles) = (self.tree, self.styles); for_children_in_paint_order(tree, styles, slot, |cs| { - self.paint(cs, world, op, child_clip, dl); + self.paint(cs, world, op, child_clip, sink); }); if scissored { - dl.words.push(spec::draw_op::SCISSOR_POP); + sink.pop_scissor(); } } diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index fd71ee68..e7fb8661 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -47,7 +47,7 @@ pub mod touch; pub mod tree; pub mod wire; -pub use draw::DrawList; +pub use draw::{DrawList, RetainedFrame, RetainedLayer, RetainedPass}; /// CLUT byte size: 256 entries x u32 ABGR (the GE CLUT8 palette). const TEX_PALETTE_BYTES: usize = 1024; @@ -239,6 +239,7 @@ pub struct Ui { raster_revision: u64, focused: i32, draw_list: DrawList, + retained_frame: RetainedFrame, /// Virtual cursor sprite (spec ops 28/29, input.cursor capability): /// texture handle (< 0 = hidden), hotspot offset into the sprite, /// logical draw size (0 = the texture's own pixel size), and the @@ -299,6 +300,7 @@ impl Ui { raster_revision: 1, focused: 0, draw_list: DrawList::new(), + retained_frame: RetainedFrame::default(), cursor_tex: -1, cursor_hot: (0.0, 0.0), cursor_size: (0.0, 0.0), @@ -1154,6 +1156,65 @@ impl Ui { &self.draw_list } + /// Build ordered transparent raster-cache passes for capable hosts. + /// + /// A node whose `rasterCache` prop is `RasterCache::Retained` becomes a + /// [`RetainedPass::Layer`] when its world transform is translation-only. + /// Its local DrawList remains stable while (`x`, `y`) changes, so the host + /// can retain pixels and move them in a compositor. Unsupported transforms + /// stay in a regular pass. [`draw`](Self::draw) remains byte-compatible and + /// ignores this hint. + pub fn draw_retained(&mut self) -> &RetainedFrame { + if self.layout.needs() { + layout::relayout(&mut self.tree, &self.styles, &self.fonts, &mut self.layout); + } + let cursor = if self.cursor_tex >= 0 { + tex_resolve(&self.textures, self.cursor_tex) + .and_then(|slot| self.textures[slot as usize].tex.as_ref()) + .map(|t| { + let w = if self.cursor_size.0 > 0.0 { + self.cursor_size.0 + } else { + t.w as f32 + }; + let h = if self.cursor_size.1 > 0.0 { + self.cursor_size.1 + } else { + t.h as f32 + }; + ( + self.cursor_tex as u32, + self.cursor_pos.0 - self.cursor_hot.0, + self.cursor_pos.1 - self.cursor_hot.1, + w, + h, + ) + }) + } else { + None + }; + let (target, drawn) = draw::build_retained( + &self.tree, + &self.styles, + &self.fonts, + self.frame, + self.layout.viewport, + &mut self.textures, + &mut self.tex_free, + &mut self.discs, + self.raster_density, + &mut self.retained_frame, + self.inspect_id, + self.inspect_drawn, + cursor, + ); + self.inspect_drawn = drawn; + if self.inspect_id != 0 { + self.inspect_rect = target; + } + &self.retained_frame + } + /// Resize the logical viewport (root node + layout bounds + draw clip). /// Defaults to the PSP's 480x272; desktop hosts call this with their /// surface size. Values are clamped to the DrawList's i16 coordinate diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index deecb9b3..7f8dcf13 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -140,6 +140,20 @@ trait RenderTarget { let len = self.pixel_len(); self.fill_opaque(0, len, 0, 0, 0); } + + /// Clear to fully transparent on ARGB targets so hosts can composite + /// the scene over their own background layers. Defaults to clearing + /// black, so opaque targets are unchanged. + #[inline] + fn clear_transparent(&mut self) { + self.clear_black(); + } + + /// Region-scoped transparent clear (used for incremental damage repaints). + #[inline] + fn fill_transparent(&mut self, start: usize, len: usize, r: u32, g: u32, b: u32) { + self.fill_opaque(start, len, r, g, b); + } } struct RgbaTarget<'a, const ARGB: bool> { @@ -171,6 +185,34 @@ impl RenderTarget for RgbaTarget<'_, ARGB> { if a == 0 { return; } + // Transparent destination: write straight color + coverage and let + // the host composite (no blending against a black backdrop). + if ARGB && self.bytes[o + ai] == 0 { + self.bytes[o + ri] = r as u8; + self.bytes[o + gi] = g as u8; + self.bytes[o + bi] = b as u8; + self.bytes[o + ai] = a as u8; + return; + } + if ARGB { + // Straight-alpha src-over for destinations that already hold + // content. The previous "blend against opaque black and force + // alpha 255" flattened semi-transparent destination pixels + // (glyph edges over another layer, translucent backgrounds) on + // layered surfaces. For opaque destinations this reduces to the + // old mix (same rgb, alpha 255). + let dst_a = self.bytes[o + ai] as u32; + let out_a = a + (dst_a * (255 - a) + 127) / 255; + let dst_w = dst_a * (255 - a); + let div = out_a * 255; + let half = div / 2; + let over = |s: u32, d: u8| ((s * a * 255 + d as u32 * dst_w + half) / div) as u8; + self.bytes[o + ri] = over(r, self.bytes[o + ri]); + self.bytes[o + gi] = over(g, self.bytes[o + gi]); + self.bytes[o + bi] = over(b, self.bytes[o + bi]); + self.bytes[o + ai] = out_a as u8; + return; + } let ia = 255 - a; let mix = |s: u32, d: u8| ((s * a + d as u32 * ia + 127) / 255) as u8; self.bytes[o + ri] = mix(r, self.bytes[o + ri]); @@ -184,6 +226,25 @@ impl RenderTarget for RgbaTarget<'_, ARGB> { let byte_start = start * 4; fill_opaque_span::(&mut self.bytes[byte_start..byte_start + len * 4], r, g, b); } + + #[inline] + fn clear_transparent(&mut self) { + if ARGB { + self.bytes.fill(0); + } else { + self.clear_black(); + } + } + + #[inline] + fn fill_transparent(&mut self, start: usize, len: usize, r: u32, g: u32, b: u32) { + if ARGB { + let byte_start = start * 4; + self.bytes[byte_start..byte_start + len * 4].fill(0); + } else { + self.fill_opaque(start, len, r, g, b); + } + } } struct Rgb565Target<'a> { @@ -365,6 +426,25 @@ pub fn render_scaled_argb(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { render_scaled_impl(ui, words, &mut target, scale, true); } +/// Render transparent ARGB pixels into an explicitly sized logical surface. +/// This is the retained-layer counterpart of [`render_scaled_argb`]: fonts +/// and textures still come from `ui`, but the target need not match its +/// viewport. +pub fn render_scaled_argb_surface( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + logical_width: u32, + logical_height: u32, + scale: u32, +) { + let mut target = RgbaTarget:: { bytes: fb }; + let (width, _height, screen) = + target_geometry_surface(&target, logical_width, logical_height, scale); + target.clear_transparent(); + render_scaled_clipped(ui, words, &mut target, width, scale as i32, screen); +} + /// Execute a complete DrawList into a little-endian RGB565 framebuffer. pub fn render_scaled_rgb565(ui: &Ui, words: &[u32], fb: &mut [u16], scale: u32) { let mut target = Rgb565Target { pixels: fb }; @@ -462,6 +542,33 @@ pub fn render_scaled_argb_regions( render_scaled_regions_impl(ui, words, &mut target, scale, regions); } +/// Repaint logical damage rectangles on an explicitly sized retained ARGB +/// surface. Each rectangle is cleared transparent before replaying the full +/// layer DrawList under that clip. +pub fn render_scaled_argb_surface_regions( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + logical_width: u32, + logical_height: u32, + scale: u32, + regions: &[DamageRect], +) { + let mut target = RgbaTarget:: { bytes: fb }; + let (width, height, screen) = + target_geometry_surface(&target, logical_width, logical_height, scale); + render_damage_regions( + ui, + words, + &mut target, + width, + height, + scale as i32, + screen, + regions, + ); +} + /// RGB565 equivalent of [`render_scaled_regions`]. pub fn render_scaled_rgb565_regions( ui: &Ui, @@ -516,6 +623,43 @@ pub fn render_scaled_argb_incremental( ) } +/// Incrementally render an explicitly sized retained ARGB surface. +pub fn render_scaled_argb_surface_incremental( + ui: &Ui, + words: &[u32], + fb: &mut [u8], + logical_width: u32, + logical_height: u32, + scale: u32, + tracker: &mut DamageTracker, + policy: DamagePolicy, +) -> Result, DamageError> { + let mut target = RgbaTarget:: { bytes: fb }; + let (width, height, screen) = + target_geometry_surface(&target, logical_width, logical_height, scale); + let damage_target = DamageTarget::new( + width as u32, + height as u32, + scale, + DAMAGE_SIGNATURE_ARGB8, + ); + let plan = tracker + .prepare_surface(ui, words, damage_target, logical_width, logical_height)? + .with_policy(policy)?; + render_damage_regions( + ui, + words, + &mut target, + width, + height, + scale as i32, + screen, + plan.regions(), + ); + tracker.commit(ui, words, damage_target); + Ok(plan) +} + /// Incrementally render native RGB565 pixels. pub fn render_scaled_rgb565_incremental( ui: &Ui, @@ -546,7 +690,7 @@ fn render_scaled_impl( ) { let (width, _height, screen) = target_geometry(ui, target, scale); if clear { - target.clear_black(); + target.clear_transparent(); } render_scaled_clipped(ui, words, target, width, scale as i32, screen); } @@ -627,6 +771,47 @@ fn target_geometry(ui: &Ui, target: &T, scale: u32) -> (i32, i3 (width, height, screen) } +fn target_geometry_surface( + target: &T, + logical_width: u32, + logical_height: u32, + scale: u32, +) -> (i32, i32, Clip) { + assert!( + (1..=MAX_RENDER_SCALE).contains(&scale), + "render scale must be 1 through 4" + ); + let width = logical_width + .checked_mul(scale) + .expect("scaled surface width overflow"); + let height = logical_height + .checked_mul(scale) + .expect("scaled surface height overflow"); + assert!(width > 0 && height > 0, "surface must have positive dimensions"); + assert!( + width <= i32::MAX as u32 && height <= i32::MAX as u32, + "scaled surface dimensions exceed raster limits" + ); + let expected = width as usize * height as usize; + assert_eq!( + target.pixel_len(), + expected, + "scaled framebuffer has the wrong pixel count" + ); + let width = width as i32; + let height = height as i32; + ( + width, + height, + Clip { + x0: 0, + y0: 0, + x1: width, + y1: height, + }, + ) +} + #[allow(clippy::too_many_arguments)] fn render_damage_regions( ui: &Ui, @@ -663,7 +848,7 @@ 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); + target.fill_transparent(start, row_pixels, 0, 0, 0); } } @@ -1355,6 +1540,13 @@ mod tests { fn argb_output_uses_le_argb8888_memory_layout() { let ui = Ui::new(); let words = vec![ + // Seed a fullscreen opaque rect so both targets carry content in + // every pixel: under the transparent-clear contract uncovered + // ARGB pixels have alpha 0 while RGBA clears to opaque black. + draw_op::RECT, + xy_word(0, 0), + wh_word(spec::SCREEN_W as u16, spec::SCREEN_H as u16), + 0xff10_1010, draw_op::RECT, xy_word(3, 4), wh_word(7, 5), @@ -1392,6 +1584,32 @@ mod tests { } } + #[test] + fn straight_alpha_src_over_matches_reference_math() { + // A semi-transparent src over a semi-transparent dst must follow true + // straight-alpha src-over, not "blend against black + force alpha". + let ui = Ui::new(); + let words = vec![ + draw_op::RECT, + xy_word(2, 2), + wh_word(3, 3), + 0x4066_9966, // dst (ABGR): a=64, r=102, g=153, b=102 + draw_op::RECT, + xy_word(2, 2), + wh_word(3, 3), + 0x8033_2211, // src (ABGR): a=128, r=17, g=34, b=51 + ]; + let mut fb = framebuffer(1); + render_scaled_argb(&ui, &words, &mut fb, 1); + let o = (3 * spec::SCREEN_W as usize + 3) * 4; // pixel (3,3) + // out_a = 128 + (64*127 + 127)/255 = 160 + // dst_w = 64*127 = 8128; div = 160*255 = 40800; half = div/2 + // r = (17*128*255 + 102*8128 + 20400)/40800 = 34 + // g = (34*128*255 + 153*8128 + 20400)/40800 = 58 + // b = (51*128*255 + 102*8128 + 20400)/40800 = 61 + assert_eq!(&fb[o..o + 4], &[61, 58, 34, 160]); // LE ARGB: B,G,R,A + } + #[test] fn rgb565_output_is_native_and_ordered_fallback_preserves_existing_pixels() { let mut ui = Ui::new(); diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index f5d6ac2e..00e986e8 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -165,6 +165,7 @@ pub mod prop { pub const ARC_START: u8 = 140; pub const ARC_SWEEP: u8 = 141; pub const ARC_WIDTH: u8 = 142; + pub const RASTER_CACHE: u8 = 143; } /// How a prop's u32 payload is interpreted (see spec.ts VALUE_KIND). @@ -184,7 +185,7 @@ pub const PROP_VALUE_KIND: [u8; 256] = [ 0x01, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x02, 0x02, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, @@ -299,6 +300,14 @@ pub enum GradDir { ToRight = 3, } +/// RasterCache +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RasterCache { + None = 0, + Retained = 1, +} + /// animation easing. Spring/SpringBouncy ignore durMs (physics decide); OutBack overshoots ~10%. #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/engine/core/src/style.rs b/engine/core/src/style.rs index 2c0338d8..987c1280 100644 --- a/engine/core/src/style.rs +++ b/engine/core/src/style.rs @@ -302,6 +302,9 @@ pub struct Resolved { pub arc_start: f32, pub arc_sweep: f32, pub arc_width: f32, + /// Host opt-in raster cache mode. The ordinary flat DrawList ignores it; + /// `Ui::draw_retained` may isolate supported subtrees into layers. + pub raster_cache: u8, } impl Default for Resolved { @@ -363,6 +366,7 @@ impl Default for Resolved { arc_start: 0.0, arc_sweep: 0.0, arc_width: 0.0, + raster_cache: spec::RasterCache::None as u8, } } } @@ -438,6 +442,7 @@ impl Resolved { p::ARC_START => self.arc_start = f, p::ARC_SWEEP => self.arc_sweep = f, p::ARC_WIDTH => self.arc_width = f, + p::RASTER_CACHE => self.raster_cache = bits as u8, _ => {} } } @@ -512,6 +517,7 @@ impl Resolved { p::ARC_START => self.arc_start.to_bits(), p::ARC_SWEEP => self.arc_sweep.to_bits(), p::ARC_WIDTH => self.arc_width.to_bits(), + p::RASTER_CACHE => self.raster_cache as u32, _ => 0, } } diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index ec7a45a9..6dd48ad1 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -188,6 +188,225 @@ fn abgr(r: u8, g: u8, b: u8, a: u8) -> u32 { ((a as u32) << 24) | ((b as u32) << 16) | ((g as u32) << 8) | r as u32 } +#[test] +fn retained_layer_translation_only_changes_composite_position() { + use crate::RetainedPass; + + let mut ui = Ui::new(); + ui.set_viewport(64.0, 32.0); + + let background = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(background, spec::prop::WIDTH, 64.0); + ui.set_prop(background, spec::prop::HEIGHT, 32.0); + ui.set_prop(background, spec::prop::BG_COLOR, abgr(8, 16, 32, 255) as f64); + ui.insert_before(spec::ROOT_ID, background, 0); + + let layer = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(layer, spec::prop::POS_TYPE, spec::PosType::Absolute as u8 as f64); + ui.set_prop(layer, spec::prop::INSET_L, 8.0); + ui.set_prop(layer, spec::prop::INSET_T, 4.0); + ui.set_prop(layer, spec::prop::WIDTH, 24.0); + ui.set_prop(layer, spec::prop::HEIGHT, 16.0); + ui.set_prop(layer, spec::prop::BG_COLOR, abgr(24, 160, 96, 192) as f64); + ui.set_prop( + layer, + spec::prop::RASTER_CACHE, + spec::RasterCache::Retained as u8 as f64, + ); + ui.insert_before(spec::ROOT_ID, layer, 0); + + let overlay = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(overlay, spec::prop::POS_TYPE, spec::PosType::Absolute as u8 as f64); + ui.set_prop(overlay, spec::prop::INSET_L, 10.0); + ui.set_prop(overlay, spec::prop::INSET_T, 6.0); + ui.set_prop(overlay, spec::prop::WIDTH, 6.0); + ui.set_prop(overlay, spec::prop::HEIGHT, 6.0); + ui.set_prop(overlay, spec::prop::Z_INDEX, 2.0); + ui.set_prop(overlay, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.insert_before(spec::ROOT_ID, overlay, 0); + + let snapshot = |ui: &mut Ui| { + ui.draw_retained() + .passes + .iter() + .map(|pass| match pass { + RetainedPass::Draw(draw) => (0, 0, 0, draw.words.clone()), + RetainedPass::Layer(layer) => { + (layer.node_id, layer.x, layer.y, layer.draw.words.clone()) + } + }) + .collect::>() + }; + + let before = snapshot(&mut ui); + assert_eq!(before.len(), 3, "background, retained layer, foreground overlay"); + assert_eq!(before[1].0, layer); + + ui.set_prop(layer, spec::prop::TRANSLATE_Y, 3.0); + let after = snapshot(&mut ui); + + assert_eq!(before[0], after[0], "content before the layer stays retained"); + assert_eq!(before[2], after[2], "content after the layer stays retained"); + assert_eq!(before[1].0, after[1].0); + assert_eq!(before[1].1, after[1].1); + assert_eq!(before[1].2 + 3, after[1].2); + assert_eq!(before[1].3, after[1].3, "layer raster words do not move"); +} + +fn composite_argb_pixel(dst: &mut [u8], src: &[u8]) { + let a = src[3] as u32; + if a == 0 { + return; + } + if a == 255 || dst[3] == 0 { + dst.copy_from_slice(src); + return; + } + let dst_a = dst[3] as u32; + let out_a = a + (dst_a * (255 - a) + 127) / 255; + let dst_w = dst_a * (255 - a); + let div = out_a * 255; + let half = div / 2; + for channel in 0..3 { + dst[channel] = ((src[channel] as u32 * a * 255 + + dst[channel] as u32 * dst_w + + half) + / div) as u8; + } + dst[3] = out_a as u8; +} + +fn raster_retained_for_test(ui: &Ui, frame: &crate::RetainedFrame) -> Vec { + use crate::RetainedPass; + + let (width, height) = ui.viewport(); + let width = width as usize; + let height = height as usize; + let mut output = vec![0u8; width * height * 4]; + for pass in &frame.passes { + match pass { + RetainedPass::Draw(draw) => { + let mut pixels = vec![0u8; output.len()]; + crate::raster::render_scaled_argb(ui, &draw.words, &mut pixels, 1); + for (dst, src) in output.chunks_exact_mut(4).zip(pixels.chunks_exact(4)) { + composite_argb_pixel(dst, src); + } + } + RetainedPass::Layer(layer) => { + let layer_width = layer.width as usize; + let layer_height = layer.height as usize; + let mut pixels = vec![0u8; layer_width * layer_height * 4]; + crate::raster::render_scaled_argb_surface( + ui, + &layer.draw.words, + &mut pixels, + layer.width, + layer.height, + 1, + ); + for local_y in 0..layer_height { + let global_y = layer.y + local_y as i32; + if global_y < 0 + || global_y >= height as i32 + || global_y < layer.clip.y0 + || global_y >= layer.clip.y1 + { + continue; + } + for local_x in 0..layer_width { + let global_x = layer.x + local_x as i32; + if global_x < 0 + || global_x >= width as i32 + || global_x < layer.clip.x0 + || global_x >= layer.clip.x1 + { + continue; + } + let src = (local_y * layer_width + local_x) * 4; + let dst = (global_y as usize * width + global_x as usize) * 4; + composite_argb_pixel(&mut output[dst..dst + 4], &pixels[src..src + 4]); + } + } + } + } + } + output +} + +#[test] +fn retained_layers_match_flat_argb_pixels_when_translated_and_clipped() { + let mut ui = Ui::new(); + ui.set_viewport(48.0, 28.0); + + let background = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(background, spec::prop::WIDTH, 48.0); + ui.set_prop(background, spec::prop::HEIGHT, 28.0); + ui.set_prop(background, spec::prop::BG_COLOR, abgr(7, 18, 31, 255) as f64); + ui.insert_before(spec::ROOT_ID, background, 0); + + let clip = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(clip, spec::prop::POS_TYPE, spec::PosType::Absolute as u8 as f64); + ui.set_prop(clip, spec::prop::INSET_L, 6.0); + ui.set_prop(clip, spec::prop::INSET_T, 5.0); + ui.set_prop(clip, spec::prop::WIDTH, 28.0); + ui.set_prop(clip, spec::prop::HEIGHT, 14.0); + ui.set_prop(clip, spec::prop::OVERFLOW, spec::Overflow::Hidden as u8 as f64); + ui.insert_before(spec::ROOT_ID, clip, 0); + + let layer = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(layer, spec::prop::POS_TYPE, spec::PosType::Absolute as u8 as f64); + ui.set_prop(layer, spec::prop::INSET_L, -3.0); + ui.set_prop(layer, spec::prop::INSET_T, 2.0); + ui.set_prop(layer, spec::prop::WIDTH, 36.0); + ui.set_prop(layer, spec::prop::HEIGHT, 10.0); + ui.set_prop(layer, spec::prop::BG_COLOR, abgr(220, 70, 40, 173) as f64); + ui.set_prop( + layer, + spec::prop::RASTER_CACHE, + spec::RasterCache::Retained as u8 as f64, + ); + ui.insert_before(clip, layer, 0); + + let child = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(child, spec::prop::POS_TYPE, spec::PosType::Absolute as u8 as f64); + ui.set_prop(child, spec::prop::INSET_L, 8.0); + ui.set_prop(child, spec::prop::INSET_T, 2.0); + ui.set_prop(child, spec::prop::WIDTH, 11.0); + ui.set_prop(child, spec::prop::HEIGHT, 5.0); + ui.set_prop(child, spec::prop::BG_COLOR, abgr(45, 190, 240, 211) as f64); + ui.insert_before(layer, child, 0); + + let overlay = ui.create_node(spec::NodeType::View as u8); + ui.set_prop(overlay, spec::prop::POS_TYPE, spec::PosType::Absolute as u8 as f64); + ui.set_prop(overlay, spec::prop::INSET_L, 14.0); + ui.set_prop(overlay, spec::prop::INSET_T, 9.0); + ui.set_prop(overlay, spec::prop::WIDTH, 8.0); + ui.set_prop(overlay, spec::prop::HEIGHT, 4.0); + ui.set_prop(overlay, spec::prop::Z_INDEX, 3.0); + ui.set_prop(overlay, spec::prop::BG_COLOR, abgr(250, 245, 210, 231) as f64); + ui.insert_before(spec::ROOT_ID, overlay, 0); + + for translate_y in [-4.0, 0.0, 6.0] { + ui.set_prop(layer, spec::prop::TRANSLATE_Y, translate_y); + let flat = ui.draw().words.clone(); + let retained = ui.draw_retained().clone(); + let mut expected = vec![0u8; 48 * 28 * 4]; + crate::raster::render_scaled_argb(&ui, &flat, &mut expected, 1); + let actual = raster_retained_for_test(&ui, &retained); + let max_delta = actual + .iter() + .zip(&expected) + .map(|(&a, &b)| a.abs_diff(b)) + .max() + .unwrap_or(0); + assert!( + max_delta <= 1, + "retained composition must preserve flat pixels within compositor rounding at \ + translateY={translate_y}; max channel delta={max_delta}" + ); + } +} + // ---- DrawList decoding helpers ------------------------------------------------ fn decode_xy(word: u32) -> (i32, i32) { diff --git a/engine/core/src/text.rs b/engine/core/src/text.rs index bc3fe15e..cf470316 100644 --- a/engine/core/src/text.rs +++ b/engine/core/src/text.rs @@ -108,7 +108,14 @@ impl Atlas { if bytes.len() < bitmap_end { return None; } - let mut cmap = Vec::with_capacity(glyph_count as usize); + // Fallible allocation: at runtime the PSRAM heap can be too + // fragmented for a multi-MB atlas bitmap (observed on esp32p4: a + // 1.5 MB lyric-glyph bake alloc aborted the whole app). OOM must + // degrade to load() == false (tofu glyphs), never abort. + let mut cmap: Vec = Vec::new(); + if cmap.try_reserve_exact(glyph_count as usize).is_err() { + return None; + } for i in 0..glyph_count as usize { let o = cmap_off + i * fa::CMAP_ENTRY_SIZE; let gid = rd_u16(bytes, o + 4)?; @@ -122,7 +129,10 @@ impl Atlas { xoff: *bytes.get(o + 7)?, }); } - let mut bitmap = Vec::with_capacity(bitmap_len); + let mut bitmap: Vec = Vec::new(); + if bitmap.try_reserve_exact(bitmap_len).is_err() { + return None; + } bitmap.extend_from_slice(&bytes[bitmap_off..bitmap_end]); Some(Atlas { cell_w, diff --git a/framework/compiler/bake-font.ts b/framework/compiler/bake-font.ts index 55a43f4c..01d8c591 100644 --- a/framework/compiler/bake-font.ts +++ b/framework/compiler/bake-font.ts @@ -58,6 +58,9 @@ export interface BakeOptions { slots: number[]; /** Extra characters to force into every atlas [R]. */ extraChars?: string; + /** Extra characters baked only into specific slots (e.g. { 0: "" }). + Use for large per-slot sets that must not multiply across slots. */ + slotExtraChars?: Record; /** Raster samples per logical pixel. Defaults to 1. */ rasterDensity?: number; regularTtf?: string; @@ -397,7 +400,11 @@ export async function bakeAtlases(opts: BakeOptions): Promise { const { px, bold } = fontSlotInfo(slot); const key = bold ? "bold" : "regular"; fonts[key] ??= await loadFont(bold ? (opts.boldTtf ?? DEFAULT_BOLD) : (opts.regularTtf ?? DEFAULT_REGULAR)); - results.push(bakeSlot(fonts[key]!, slot, px, bold, chars, rasterDensity)); + const slotExtra = opts.slotExtraChars?.[slot]; + const slotChars = slotExtra + ? [...new Set([...chars, ...[...slotExtra].map((ch) => ch.codePointAt(0)!).filter((cp) => cp >= 32 && cp !== 127)])].sort((a, b) => a - b) + : chars; + results.push(bakeSlot(fonts[key]!, slot, px, bold, slotChars, rasterDensity)); } return results; } diff --git a/tools/build.ts b/tools/build.ts index c7e3dfc0..7a63ffb9 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -78,6 +78,7 @@ const packageName = packageJson.name ?? "@pocketjs/framework"; const args = process.argv.slice(2); let extraChars = ""; +const slotExtraChars: Record = {}; let regularFontPath: string | undefined; let boldFontPath: string | undefined; let appArg = ""; @@ -90,6 +91,12 @@ let densityFlag: number | undefined; let projectRoot = process.cwd(); for (const a of args) { if (a.startsWith("--extra-chars=")) extraChars = a.slice("--extra-chars=".length); + else if (a.startsWith("--slot-extra-chars=")) { + // --slot-extra-chars=: — bake file's chars into one slot only + const spec = a.slice("--slot-extra-chars=".length); + const sep = spec.indexOf(":"); + if (sep > 0) slotExtraChars[Number(spec.slice(0, sep))] = await Bun.file(resolvePath(spec.slice(sep + 1))).text(); + } else if (a.startsWith("--font-regular=")) regularFontPath = resolvePath(a.slice("--font-regular=".length)); else if (a.startsWith("--font-bold=")) boldFontPath = resolvePath(a.slice("--font-bold=".length)); else if (a.startsWith("--framework=")) frameworkFlag = a.slice("--framework=".length); @@ -298,6 +305,7 @@ const atlases = await bakeAtlases({ codepoints, slots: styles.usedFontSlots, extraChars, + slotExtraChars, rasterDensity, regularTtf: regularFontPath, boldTtf: boldFontPath,