Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contracts/spec/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -485,6 +489,7 @@ export const PROP_VALUE_KIND: Record<PropName, number> = {
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,
};

// ---------------------------------------------------------------------------
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node> + free list + GENERATION COUNTER
Expand Down
63 changes: 63 additions & 0 deletions docs/RETAINED_LAYERS.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 103 additions & 1 deletion engine/core/src/damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,55 @@ impl<const MAX_REGIONS: usize> DamageTracker<MAX_REGIONS> {
ui: &Ui,
words: &[u32],
target: DamageTarget,
) -> Result<DamagePlan<MAX_REGIONS>, 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<DamagePlan<MAX_REGIONS>, 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<DamagePlan<MAX_REGIONS>, 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 {
Expand Down Expand Up @@ -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<const MAX_REGIONS: usize>(
ui: &Ui,
words: &[u32],
logical_width: u32,
logical_height: u32,
) -> Result<DamagePlan<MAX_REGIONS>, 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();
Expand Down Expand Up @@ -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::<DEFAULT_DAMAGE_REGIONS>(&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();
Expand Down
Loading
Loading