Embedded React: keep transform sources across frames. Modified from Embedded React (Apache-2.0, Copyright 2026 Cory Lamming) at the revision pinned in dependencies.lock.json, in the patch order listed there. Changed files: engine/CMakeLists.txt, engine/animation/animation.c, engine/core/er_limits.h, engine/rendering/image_scaler.c, engine/rendering/image_scaler.h, engine/rendering/transform.c, engine/rendering/transform.h, engine/scene/compositor.c, engine/scene/er_node_internal.h and engine/tests/rendering/CMakeLists.txt; adds engine/tests/rendering/test_transform_cache.c. Not proposed upstream: PS5 React maintains this change here. A scaled or rotated node is painted by rendering its subtree into a transform source and mapping that out. The capture ran on every frame of a scale animation although only the matrix changed, and once in every parallel slice the node straddled. On PS5, a focus scale on a 4K row card made each frame of the move re-render the card in up to four slices: 30-43 ms of render per frame, against 3 ms for the same move without the scale. ERUI_XFORM_CACHE_SLOTS (default 0, compiled out) keeps that many 2D sources, shared by every render worker. A source is keyed by the node, its size, the image registry's generation and a new per-node content_stamp, which every change to what the subtree paints advances along the ancestor chain. An animated change to a node's own translate, scale or rotation leaves that node's stamp alone. A hit walks the subtree for its paint bookkeeping only and emits the kept pixels through the current matrix. A source is stored once the same content has been captured twice, and only when it was captured whole. Lookups and claims take the serial lock, and a slot used in the current commit is never evicted while another worker may be reading it. test_transform_cache records a scale animation with changing content twice, with kept sources and with a fresh capture forced on every frame, and requires every frame to match. It also checks that a kept source is really reused and that re-registering an image invalidates it. diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 938107f..d71c0a1 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -108,6 +108,7 @@ set(ERUI_SCRATCH_H "240" CACHE STRING "Transform-source scratch height in pixels set(ERUI_SCRATCH_BAND_H "${ERUI_SCRATCH_H}" CACHE STRING "Opacity-composite strip height; smaller = less RAM, taller fades render in multiple band passes") set(ERUI_XFORM_W "${ERUI_SCRATCH_W}" CACHE STRING "Transform-source width (max rotatable/scalable node width); decouple from ERUI_SCRATCH_W when strips are screen-wide") set(ERUI_XFORM_H "${ERUI_SCRATCH_H}" CACHE STRING "Transform-source height (max rotatable/scalable node height)") +set(ERUI_XFORM_CACHE_SLOTS "0" CACHE STRING "Transform sources kept across frames for scale/rotate animations, shared by all workers (each ERUI_XFORM_W x ERUI_XFORM_H pixels); 0 disables") set(ERUI_FADE_CACHE_W "0" CACHE STRING "Fade-cache width: composited-subtree cache reused across frames of an opacity animation; 0 disables") set(ERUI_FADE_CACHE_H "0" CACHE STRING "Fade-cache height; 0 disables") set(ERUI_FONT_POOL_BYTES "0" CACHE STRING "Static pool for runtime-loaded fonts (bytes); 0 disables er_font_load") @@ -181,6 +182,7 @@ target_compile_definitions(embedded-react PUBLIC ERUI_SCRATCH_BAND_H=${ERUI_SCRATCH_BAND_H} ERUI_XFORM_W=${ERUI_XFORM_W} ERUI_XFORM_H=${ERUI_XFORM_H} + ERUI_XFORM_CACHE_SLOTS=${ERUI_XFORM_CACHE_SLOTS} ERUI_FADE_CACHE_W=${ERUI_FADE_CACHE_W} ERUI_FADE_CACHE_H=${ERUI_FADE_CACHE_H} ERUI_IMAGE_REGISTRY_MAX=${ERUI_IMAGE_REGISTRY_MAX} diff --git a/engine/animation/animation.c b/engine/animation/animation.c index 39542cc..0f31ee6 100644 --- a/engine/animation/animation.c +++ b/engine/animation/animation.c @@ -375,6 +375,14 @@ static void update_has_transform(ERNode* node) er_request_subtree_bounds_pass(); } +/** @brief Whether @p prop is part of the node's own transform, which a capture of its subtree excludes. */ +static bool is_transform_prop(ERAnimProp prop) +{ + return prop == ER_PROP_TRANSLATE_X || prop == ER_PROP_TRANSLATE_Y || prop == ER_PROP_SCALE_X + || prop == ER_PROP_SCALE_Y || prop == ER_PROP_ROTATE_Z || prop == ER_PROP_ROTATE_X + || prop == ER_PROP_ROTATE_Y; +} + /** * @brief Applies a numeric value to a node property. * @@ -905,6 +913,8 @@ static void push_to_value_bindings(ERAnimValue* val) n->sweep_moved = false; /* anything else changed too: the whole box repaints */ if (bind->prop == ER_PROP_OPACITY) er_mark_dirty_upward_visual(n); + else if (is_transform_prop(bind->prop)) + er_mark_dirty_upward_transform(n); else er_mark_dirty_upward(n); } @@ -1921,6 +1931,8 @@ void er_anim_tick(uint32_t delta_ms) /* Opacity-only animation frames keep the fade cache valid (see push_to_value_bindings). */ if (anim->prop == ER_PROP_OPACITY) er_mark_dirty_upward_visual(node); + else if (is_transform_prop(anim->prop)) + er_mark_dirty_upward_transform(node); else er_mark_dirty_upward(node); diff --git a/engine/core/er_limits.h b/engine/core/er_limits.h index f9d14ed..1e932b7 100644 --- a/engine/core/er_limits.h +++ b/engine/core/er_limits.h @@ -72,6 +72,13 @@ #define ERUI_XFORM_H ERUI_SCRATCH_H #endif +/* Transform sources kept across frames, shared by all render workers, each ERUI_XFORM_W x ERUI_XFORM_H + * pixels. A scale or rotate animation re-captures an unchanged subtree every frame, and in every parallel + * slice it straddles; a kept source turns each of those captures into a lookup. 0 compiles the cache out. */ +#ifndef ERUI_XFORM_CACHE_SLOTS +#define ERUI_XFORM_CACHE_SLOTS 0 +#endif + /** * @brief Static row-buffer width for the per-row rasterizers. * diff --git a/engine/rendering/image_scaler.c b/engine/rendering/image_scaler.c index 51257fd..41ceb35 100644 --- a/engine/rendering/image_scaler.c +++ b/engine/rendering/image_scaler.c @@ -440,23 +440,34 @@ static void render_region(const ImageEntry* img, - Functions: Public ---------------------------------------------------------------------------------------------------------------------*/ +static uint32_t s_image_generation = 1U; + +uint32_t er_image_generation(void) +{ + return s_image_generation; +} + void er_image_load(const char* name, const void* argb_buf, int w, int h) { + s_image_generation++; image_registry_store(name, argb_buf, w, h, ER_IMG_ARGB8888); } void er_image_load_rgb565(const char* name, const void* rgb565_buf, int w, int h) { + s_image_generation++; image_registry_store(name, rgb565_buf, w, h, ER_IMG_RGB565); } bool er_image_load_argb(const char* name, const void* argb_buf, int w, int h, bool opaque) { + s_image_generation++; return image_registry_store_argb(name, argb_buf, w, h, opaque); } void er_image_unload(const char* name) { + s_image_generation++; image_registry_remove(name); } diff --git a/engine/rendering/image_scaler.h b/engine/rendering/image_scaler.h index 31546c2..be49193 100644 --- a/engine/rendering/image_scaler.h +++ b/engine/rendering/image_scaler.h @@ -37,4 +37,10 @@ */ void er_image_render(const ERImageProps* props, int x, int y, int w, int h); +/** + * @brief Advanced whenever an image is loaded or unloaded, so a cache of drawn pixels can tell that + * an image it captured may since have changed under the same name. + */ +uint32_t er_image_generation(void); + #endif diff --git a/engine/rendering/transform.c b/engine/rendering/transform.c index c1bea32..8d4c7b2 100644 --- a/engine/rendering/transform.c +++ b/engine/rendering/transform.c @@ -41,6 +41,10 @@ static uint32_t s_xform_src_pool[ERUI_RENDER_WORKERS][ERUI_XFORM_H * ERUI_XFORM_ * (≤ ERUI_XFORM_W pixels), so the destination AABB is not size-limited — only the * SOURCE subtree must fit the scratch. */ static uint32_t s_xform_row_pool[ERUI_RENDER_WORKERS][ERUI_XFORM_W]; +#if ERUI_XFORM_CACHE_SLOTS > 0 +/* Kept transform sources (see er_transform_cache_store); the compositor owns which subtree each holds. */ +static uint32_t s_xform_cache_px[ERUI_XFORM_CACHE_SLOTS][ERUI_XFORM_H * ERUI_XFORM_W]; +#endif /** * @brief Per-worker transform-capture state (see er_render_worker_id). @@ -168,10 +172,12 @@ static bool xform_emit_bounds(int dst_x, int dst_y, int dst_w, int dst_h, int* e /** * @brief Inverse-maps the source scratch through a 2D affine transform and blends it out. * - * Shared by the end-of-capture blit and the banded replay blit. Streams one row segment at a - * time through xrowbuf(); the emit region is bounded to the active scissor. + * Shared by the end-of-capture blit, the banded replay blit and the kept-source blit, which pass the + * source buffer (row stride ERUI_XFORM_W). Streams one row segment at a time through xrowbuf(); the emit + * region is bounded to the active scissor. */ -static void xform_emit_2d(int src_x, +static void xform_emit_2d(const uint32_t* const src, + int src_x, int src_y, int src_w, int src_h, @@ -190,8 +196,7 @@ static void xform_emit_2d(int src_x, if (!xform_emit_bounds(dst_x, dst_y, dst_w, dst_h, &ex0, &ey0, &ex1, &ey1)) return; - /* The worker's buffers, looked up once: every lookup is a call into the host. */ - const uint32_t* const src = xsrc(); + /* The worker's buffer, looked up once: every lookup is a call into the host. */ uint32_t* const rowbuf = xrowbuf(); /* Scale-only fast path: with no rotation/shear (ib == ic == 0) the inverse map is separable — @@ -892,7 +897,7 @@ void er_transform_source_replay_blit(int src_x, #if ERUI_TRANSFORMS_FULL if (xc()->active || dst_w <= 0 || dst_h <= 0) return; - xform_emit_2d(src_x, src_y, src_w, src_h, ia, ib, ic, id, itx, ity, dst_x, dst_y, dst_w, dst_h); + xform_emit_2d(xsrc(), src_x, src_y, src_w, src_h, ia, ib, ic, id, itx, ity, dst_x, dst_y, dst_w, dst_h); #else (void)src_x; (void)src_y; @@ -939,8 +944,65 @@ void er_transform_source_end_blit(int src_x, if (dst_w <= 0 || dst_h <= 0) return; - xform_emit_2d(src_x, src_y, src_w, src_h, ia, ib, ic, id, itx, ity, dst_x, dst_y, dst_w, dst_h); + xform_emit_2d(xsrc(), src_x, src_y, src_w, src_h, ia, ib, ic, id, itx, ity, dst_x, dst_y, dst_w, dst_h); +#else + (void)src_x; + (void)src_y; + (void)src_w; + (void)src_h; + (void)ia; + (void)ib; + (void)ic; + (void)id; + (void)itx; + (void)ity; + (void)dst_x; + (void)dst_y; + (void)dst_w; + (void)dst_h; +#endif +} + +void er_transform_cache_store(int slot, int w, int h) +{ +#if ERUI_TRANSFORMS_FULL && ERUI_XFORM_CACHE_SLOTS > 0 + if (xc()->active || slot < 0 || slot >= ERUI_XFORM_CACHE_SLOTS || !er_transform_source_fits(w, h)) + return; + const uint32_t* const src = xsrc(); + for (int row = 0; row < h; row++) + memcpy(s_xform_cache_px[slot] + (size_t)row * ERUI_XFORM_W, + src + (size_t)row * ERUI_XFORM_W, + (size_t)w * sizeof(uint32_t)); +#else + (void)slot; + (void)w; + (void)h; +#endif +} + +void er_transform_cache_blit(int slot, + int src_x, + int src_y, + int src_w, + int src_h, + float ia, + float ib, + float ic, + float id, + float itx, + float ity, + int dst_x, + int dst_y, + int dst_w, + int dst_h) +{ +#if ERUI_TRANSFORMS_FULL && ERUI_XFORM_CACHE_SLOTS > 0 + if (slot < 0 || slot >= ERUI_XFORM_CACHE_SLOTS || dst_w <= 0 || dst_h <= 0) + return; + xform_emit_2d( + s_xform_cache_px[slot], src_x, src_y, src_w, src_h, ia, ib, ic, id, itx, ity, dst_x, dst_y, dst_w, dst_h); #else + (void)slot; (void)src_x; (void)src_y; (void)src_w; diff --git a/engine/rendering/transform.h b/engine/rendering/transform.h index c5a011c..cde2250 100644 --- a/engine/rendering/transform.h +++ b/engine/rendering/transform.h @@ -402,6 +402,40 @@ void er_transform_source_end_blit(int src_x, int dst_w, int dst_h); +/** + * @brief Copies the calling worker's last 2D transform source into a kept slot. + * + * Call after er_transform_source_end_blit(), which leaves the source buffer intact. The slot is shared by + * every render worker: the compositor decides which subtree a slot holds and serializes the claims. + * + * @param[in] slot Slot index, below ERUI_XFORM_CACHE_SLOTS. + * @param[in] w Captured width, as passed to er_transform_source_begin(). + * @param[in] h Captured height. + */ +void er_transform_cache_store(int slot, int w, int h); + +/** + * @brief Emits a kept transform source through a 2D affine transform, like er_transform_source_end_blit(). + * + * Blends at the current draw alpha and is bounded to the active scissor. Reads the slot only, so any + * number of workers may emit the same slot at once. + */ +void er_transform_cache_blit(int slot, + int src_x, + int src_y, + int src_w, + int src_h, + float ia, + float ib, + float ic, + float id, + float itx, + float ity, + int dst_x, + int dst_y, + int dst_w, + int dst_h); + /*---------------------------------------------------------------------------------------------------------------------- - The capture-eligibility rule, in one place * diff --git a/engine/scene/compositor.c b/engine/scene/compositor.c index 53b2b1b..a8c10e3 100644 --- a/engine/scene/compositor.c +++ b/engine/scene/compositor.c @@ -553,6 +553,17 @@ void er_display_present(void) * cached subtree — which keeps the check O(1) and always correct. */ static uint32_t s_content_gen = 1U; +/* Source of content_stamp values; starts above the 0 a fresh node holds. */ +static uint32_t s_content_stamp = 1U; + +/** @brief Gives @p node and every ancestor a new content_stamp. */ +static void stamp_content_upward(ERNode* node) +{ + const uint32_t stamp = ++s_content_stamp; + for (; node; node = er_get_node(node->parent_tag)) + node->content_stamp = stamp; +} + void er_mark_dirty_upward_visual(ERNode* node) { /* A node inside a display:none subtree cannot paint, so changing it changes no pixels. Marking @@ -563,6 +574,7 @@ void er_mark_dirty_upward_visual(ERNode* node) * shown, which is when the change becomes visible. */ if (node && node->subtree_hidden) return; + stamp_content_upward(node); /* Mark only the initiating node as source_dirty so the dirty-rect * accumulator can track which pixels actually changed, not which @@ -583,6 +595,15 @@ void er_mark_dirty_upward(ERNode* node) er_mark_dirty_upward_visual(node); } +void er_mark_dirty_upward_transform(ERNode* node) +{ + if (!node) + return; + const uint32_t own = node->content_stamp; + er_mark_dirty_upward(node); + node->content_stamp = own; +} + /** * @brief Marks the chain for repaint WITHOUT claiming any damage for the node itself. * @@ -598,6 +619,7 @@ static void mark_reflow_upward(ERNode* node) s_content_gen++; /* a reflow does change rendered content, so the fade cache must drop */ if (node && node->subtree_hidden) return; /* same reasoning as er_mark_dirty_upward_visual: a hidden node can never clear it */ + stamp_content_upward(node); while (node) { node->dirty = true; @@ -1031,6 +1053,7 @@ static void propagate_hidden(ERNode* n, bool ancestor_hidden) { n->dirty = true; n->source_dirty = true; + stamp_content_upward(n); } } @@ -1770,6 +1793,137 @@ static void fade_cache_invalidate(void) #endif } +/* Kept transform sources (pixels in transform.c). A 2D capture is reused while its node keeps the same + * content_stamp, the image registry the same generation, and the node the same size. The node's own + * transform is not in the key, which is the point: every frame of a scale animation would otherwise + * re-render the same subtree, once in each parallel slice it straddles. + * + * The slots are shared by all render workers. Claims and lookups take the serial lock, and a slot looked + * up or claimed this commit is never evicted, since another worker may still be reading it. */ +#define ER_XF_CACHE_ENABLED (ERUI_TRANSFORMS_FULL && ERUI_XFORM_CACHE_SLOTS > 0) +#if ER_XF_CACHE_ENABLED +typedef struct +{ + uint16_t tag; + uint32_t serial; + uint32_t stamp; + uint32_t image_gen; + int w; + int h; + uint32_t used; /* s_commit_seq of the last lookup or claim */ + bool ready; /* false while empty, and while a claim is still copying its pixels */ +} XfCacheSlot; + +static XfCacheSlot s_xf_cache[ERUI_XFORM_CACHE_SLOTS]; + +/** @brief Whether the slots can be shared right now: always outside a fork-join, inside one only with the lock. */ +static bool xf_cache_usable(void) +{ + return !s_parallel_render || er_render_serial_supported(); +} + +static bool xf_cache_key_matches(const XfCacheSlot* slot, const ERNode* n, int w, int h, uint32_t image_gen) +{ + return slot->tag == n->tag && slot->serial == n->serial && slot->stamp == n->content_stamp + && slot->image_gen == image_gen && slot->w == w && slot->h == h; +} + +/** + * @brief The slot holding @p n's current w×h source, or -1. + * + * @return Slot index, marked used by this commit. + */ +static int xf_cache_find(const ERNode* n, int w, int h) +{ + if (!xf_cache_usable()) + return -1; + const uint32_t image_gen = er_image_generation(); + int found = -1; + er_render_serial_begin(); + for (int i = 0; i < ERUI_XFORM_CACHE_SLOTS; i++) + { + XfCacheSlot* slot = &s_xf_cache[i]; + if (slot->ready && xf_cache_key_matches(slot, n, w, h, image_gen)) + { + slot->used = s_commit_seq; + found = i; + break; + } + } + er_render_serial_end(); + return found; +} + +/** + * @brief Keeps the source the calling worker just captured for @p n, once its content repeats. + * + * Only the second capture of the same content is kept: a subtree whose content changes every frame would + * otherwise copy itself out on every frame and evict sources that do repeat. A source is kept only when + * it was captured whole: inside the root, where the occlusion cull reads no screen edge, and outside a + * banded pass, whose strip pruning can leave rows out of it. + */ +static void xf_cache_store(ERNode* n, int px, int py, int w, int h) +{ + if (!xf_cache_usable() || er_band_active(NULL, NULL)) + return; + const ERNode* root = er_get_root_node(); + if (!root || px < root->computed.x || py < root->computed.y || px + w > root->computed.x + root->computed.w + || py + h > root->computed.y + root->computed.h) + return; + const uint32_t image_gen = er_image_generation(); + int victim = -1; + int victim_rank = 0; + er_render_serial_begin(); + const bool repeated = n->xf_capture_stamp == n->content_stamp; + n->xf_capture_stamp = n->content_stamp; + for (int i = 0; repeated && i < ERUI_XFORM_CACHE_SLOTS; i++) + { + const XfCacheSlot* slot = &s_xf_cache[i]; + if (xf_cache_key_matches(slot, n, w, h, image_gen)) + { + victim = -1; /* kept already, or being kept by another slice */ + break; + } + if (slot->used == s_commit_seq) + continue; + int rank = 1; /* least recently used */ + if (!slot->ready) + rank = 3; + else + { + const ERNode* owner = er_get_node(slot->tag); + if (!owner || owner->serial != slot->serial || owner->content_stamp != slot->stamp + || slot->image_gen != image_gen) + rank = 2; + } + if (rank > victim_rank || (rank == victim_rank && slot->used < s_xf_cache[victim].used)) + { + victim = i; + victim_rank = rank; + } + } + if (victim >= 0) + { + XfCacheSlot* slot = &s_xf_cache[victim]; + slot->tag = n->tag; + slot->serial = n->serial; + slot->stamp = n->content_stamp; + slot->image_gen = image_gen; + slot->w = w; + slot->h = h; + slot->used = s_commit_seq; + slot->ready = false; + } + er_render_serial_end(); + if (victim < 0) + return; + er_transform_cache_store(victim, w, h); + er_render_serial_begin(); + s_xf_cache[victim].ready = true; + er_render_serial_end(); +} +#endif + #if ER_PROF /* TEMP on-device profiling: phase accumulators printed every ER_PROF_INTERVAL commits (host provides * the clock). The interval is named because the line it prints states it — a literal in both the @@ -2362,6 +2516,9 @@ static void render_tree(ERNode* n, bool parent_dirty, bool occluded, int transla /* --- 2D/3D transform application --- */ bool doing_affine = false; bool doing_replay = false; +#if ER_XF_CACHE_ENABLED + int xf_slot = -1; /* a kept source stands in for the capture (see xf_cache_find) */ +#endif int dst_x = 0, dst_y = 0, dst_w = 0, dst_h = 0; float xf_ia = 1.0f, xf_ib = 0.0f, xf_ic = 0.0f, xf_id = 1.0f, xf_itx = 0.0f, xf_ity = 0.0f; #if ERUI_3D_TRANSFORMS && ERUI_TRANSFORMS_FULL @@ -2428,6 +2585,15 @@ static void render_tree(ERNode* n, bool parent_dirty, bool occluded, int transla { doing_replay = true; } +#if ER_XF_CACHE_ENABLED + /* Inside an outer capture this node would paint untransformed (only one capture runs at a + * time), so a kept source, which emits transformed, is no substitute there. */ + else if (needs_paint && !cc()->xf_capturing && er_transform_source_fits(w, h) + && (xf_slot = xf_cache_find(n, w, h)) >= 0) + { + doing_affine = true; + } +#endif else if (er_transform_source_begin(px, py, w, h)) { doing_affine = true; @@ -2639,7 +2805,18 @@ static void render_tree(ERNode* n, bool parent_dirty, bool occluded, int transla node_opacity = n->props.text_input.opacity; bool composited = false; - if (node_opacity < 255U && should_render) +#if ER_XF_CACHE_ENABLED + if (xf_slot >= 0) + { + /* The kept source already holds these pixels: walk the subtree for its bookkeeping only, as the + * capture would have with no clip (an occluded walk records the same paint rects). */ + er_push_clip_reset(); + render_node_content(n, needs_paint, true, px, py, w, h, translate_x, translate_y); + er_pop_clip_rect(); + composited = true; + } +#endif + if (node_opacity < 255U && should_render && !composited) { composited = composite_from_cache(n, node_opacity, px, py, w, h, translate_x, translate_y); if (!composited) @@ -2667,8 +2844,21 @@ static void render_tree(ERNode* n, bool parent_dirty, bool occluded, int transla er_transform_source_end_blit_3d(px, py, w, h, xf_inv_H, dst_x, dst_y, dst_w, dst_h); else #endif +#if ER_XF_CACHE_ENABLED + if (xf_slot >= 0) + er_transform_cache_blit( + xf_slot, px, py, w, h, xf_ia, xf_ib, xf_ic, xf_id, xf_itx, xf_ity, dst_x, dst_y, dst_w, dst_h); + else + { er_transform_source_end_blit( px, py, w, h, xf_ia, xf_ib, xf_ic, xf_id, xf_itx, xf_ity, dst_x, dst_y, dst_w, dst_h); + if (needs_paint) + xf_cache_store(n, px, py, w, h); + } +#else + er_transform_source_end_blit( + px, py, w, h, xf_ia, xf_ib, xf_ic, xf_id, xf_itx, xf_ity, dst_x, dst_y, dst_w, dst_h); +#endif cc()->tile_active = xf_saved_tile_active; cc()->xf_capturing = xf_outer_capturing; cc()->xf_capture_dst = xf_outer_dst; diff --git a/engine/scene/er_node_internal.h b/engine/scene/er_node_internal.h index 6b18d22..4b9ba0e 100644 --- a/engine/scene/er_node_internal.h +++ b/engine/scene/er_node_internal.h @@ -399,6 +399,11 @@ struct ERNode uint16_t first_child_tag; uint16_t next_sibling_tag; uint32_t serial; /**< Creation count when made: tells this node from a later one in its slot (see ERNodeRef). */ + uint32_t content_stamp; /**< Advanced when anything this node paints changes, its own transform excepted: + set on the whole chain above a change, so a transform capture of this subtree + stays valid for as long as it holds the same value (see xf_cache_find). */ + uint32_t xf_capture_stamp; /**< content_stamp at this node's last transform capture: a kept source is + stored only once the same content is captured again. */ ERNodeType type; bool in_use; bool dirty; @@ -620,6 +625,14 @@ void er_mark_dirty_upward(ERNode* node); */ void er_mark_dirty_upward_visual(ERNode* node); +/** + * @brief er_mark_dirty_upward() for a change to the node's own transform (translate, scale, rotate). + * + * The node's untransformed content is unchanged, so its content_stamp is not advanced and a cached + * capture of its subtree stays valid; every ancestor's is, since the pixels they contain moved. + */ +void er_mark_dirty_upward_transform(ERNode* node); + /** * @brief Requests a layout pass on the next er_commit() (the compositor's private layout-dirty flag). * diff --git a/engine/tests/rendering/CMakeLists.txt b/engine/tests/rendering/CMakeLists.txt index d22a936..ae3e22b 100644 --- a/engine/tests/rendering/CMakeLists.txt +++ b/engine/tests/rendering/CMakeLists.txt @@ -211,3 +211,9 @@ add_executable(test_border_sweep test_border_sweep.c) target_link_libraries(test_border_sweep PRIVATE embedded-react) target_include_directories(test_border_sweep PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../core) add_test(NAME rendering_border_sweep COMMAND test_border_sweep) + +add_executable(test_transform_cache test_transform_cache.c) +target_link_libraries(test_transform_cache PRIVATE embedded-react Threads::Threads) +# test_transform_cache needs engine/core for renderer_internal.h (er_render_worker_exec and the worker build cap). +target_include_directories(test_transform_cache PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../core) +add_test(NAME rendering_transform_cache COMMAND test_transform_cache) diff --git a/engine/tests/rendering/test_transform_cache.c b/engine/tests/rendering/test_transform_cache.c new file mode 100644 index 0000000..de14abd --- /dev/null +++ b/engine/tests/rendering/test_transform_cache.c @@ -0,0 +1,401 @@ +/* + * Copyright 2026 Cory Lamming + * Copyright 2026 PS5 React contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Kept transform sources (ERUI_XFORM_CACHE_SLOTS) must paint exactly what a fresh capture paints. + * + * A card scales on the native driver while its content changes underneath: a prop change on a child, + * then a translate animation on that child. The run is recorded twice, once letting the cache work and + * once re-registering an image before every frame, which advances the image generation and so forces a + * fresh capture each time. Every frame of the two runs must match. + * + * A probe then shows the kept source is really used: an image's pixels changed behind the registry's + * back stay unseen while the card keeps scaling, and appear once er_image_load() announces them. + * + * Multi-worker builds run it all across pthread workers with the serial lock installed, so the slices + * the card straddles share the slots. + */ + +#include "er_scene.h" +#include "native_renderer.h" +#include "renderer_internal.h" +#include +#include +#include +#include +#include + +#define FB_W 320 +#define FB_H 240 +#define FRAME_MS 16 +#define FRAMES 34 +#define SCALE_MS 480 +#define NW ERUI_RENDER_WORKERS + +#define PROBE_A 0xFF20C040U +#define PROBE_B 0xFFC02040U + +typedef struct +{ + uint32_t* fb; +} TestCtx; + +static uint32_t s_probe[16 * 16]; + +static uint32_t div255(uint32_t v) +{ + return (v + 127U) / 255U; +} + +static uint32_t over(uint32_t d, uint32_t sr, uint32_t sg, uint32_t sb, uint32_t sa) +{ + const uint32_t inv = 255U - sa; + return 0xFF000000U | ((sr + div255(((d >> 16) & 0xFFU) * inv)) << 16) + | ((sg + div255(((d >> 8) & 0xFFU) * inv)) << 8) | (sb + div255((d & 0xFFU) * inv)); +} + +static void fill_cb(uint32_t argb, int x, int y, int w, int h, void* ctx) +{ + TestCtx* t = ctx; + const uint32_t a = (argb >> 24) & 0xFFU; + for (int row = y; a && row < y + h; row++) + for (int col = x; col < x + w; col++) + if (row >= 0 && row < FB_H && col >= 0 && col < FB_W) + { + uint32_t* d = &t->fb[row * FB_W + col]; + *d = over(*d, + div255(((argb >> 16) & 0xFFU) * a), + div255(((argb >> 8) & 0xFFU) * a), + div255((argb & 0xFFU) * a), + a); + } +} + +static void blend_cb(const void* src, int stride, uint8_t alpha, int x, int y, int w, int h, void* ctx) +{ + TestCtx* t = ctx; + for (int row = 0; row < h; row++) + { + const uint32_t* s = (const uint32_t*)((const uint8_t*)src + (size_t)row * (size_t)stride); + for (int col = 0; col < w; col++) + { + const int dx = x + col, dy = y + row; + const uint32_t sp = s[col]; + const uint32_t sa = div255(((sp >> 24) & 0xFFU) * alpha); + if (dx < 0 || dx >= FB_W || dy < 0 || dy >= FB_H || sa == 0U) + continue; + uint32_t* d = &t->fb[dy * FB_W + dx]; + *d = over(*d, + div255(((sp >> 16) & 0xFFU) * alpha), + div255(((sp >> 8) & 0xFFU) * alpha), + div255((sp & 0xFFU) * alpha), + sa); + } + } +} + +static void copy_cb(const void* src, int stride, int x, int y, int w, int h, void* ctx) +{ + blend_cb(src, stride, 255U, x, y, w, h, ctx); +} + +static int fail(const char* msg) +{ + fprintf(stderr, "FAIL: %s\n", msg); + return EXIT_FAILURE; +} + +static ERProps props_default(void) +{ + ERProps p = {0}; + p.left = p.top = p.right = p.bottom = ER_LAYOUT_AUTO; + p.width = p.height = ER_LAYOUT_AUTO; + p.min_width = p.max_width = ER_LAYOUT_AUTO; + p.min_height = p.max_height = ER_LAYOUT_AUTO; + p.padding = p.padding_left = p.padding_top = ER_LAYOUT_AUTO; + p.padding_right = p.padding_bottom = ER_LAYOUT_AUTO; + p.margin = p.margin_left = p.margin_top = ER_LAYOUT_AUTO; + p.margin_right = p.margin_bottom = ER_LAYOUT_AUTO; + p.gap = p.row_gap = p.column_gap = ER_LAYOUT_AUTO; + p.flex_basis = ER_LAYOUT_AUTO; + p.opacity = 255U; + return p; +} + +static ERProps box(int left, int top, int w, int h, uint32_t bg) +{ + ERProps p = props_default(); + p.position = ER_POS_ABSOLUTE; + p.left = (int16_t)left; + p.top = (int16_t)top; + p.width = (int16_t)w; + p.height = (int16_t)h; + p.background_color = bg; + return p; +} + +static void fill_probe(uint32_t argb) +{ + for (int i = 0; i < 16 * 16; i++) + s_probe[i] = argb; +} + +typedef struct +{ + ERNode* card; + ERNode* inner; + ERAnimValueHandle scale; +} Scene; + +/** + * @brief A 120x100 card scaled about its centre, holding a child box and a 40x40 image whose centre is the + * card's, so the pixel at (120, 100) shows the image at every scale. + */ +static Scene build_scene(TestCtx* t) +{ + er_reset(); + memset(t->fb, 0, sizeof(uint32_t) * FB_W * FB_H); + fill_probe(PROBE_A); + er_image_load("probe", s_probe, 16, 16); + + ERNode* root = er_node_create(ER_NODE_VIEW); + ERProps rp = box(0, 0, FB_W, FB_H, 0xFF101010U); + rp.position = ER_POS_RELATIVE; + er_node_set_props(root, &rp); + + Scene s; + s.card = er_node_create(ER_NODE_VIEW); + ERProps cp = box(60, 50, 120, 100, 0xFF2050A0U); + cp.border_radius = 12; + cp.transform_origin_x = 0.5f; + cp.transform_origin_y = 0.5f; + er_node_set_props(s.card, &cp); + + s.inner = er_node_create(ER_NODE_VIEW); + ERProps ip = box(8, 8, 30, 20, 0xFFE0C020U); + er_node_set_props(s.inner, &ip); + + ERNode* img = er_node_create(ER_NODE_IMAGE); + ERProps gp = box(40, 30, 40, 40, 0); + strncpy(gp.image_name, "probe", ER_IMAGE_NAME_MAX); + gp.resize_mode = ER_RESIZE_STRETCH; + er_node_set_props(img, &gp); + + er_tree_append_child(s.card, s.inner); + er_tree_append_child(s.card, img); + er_tree_append_child(root, s.card); + er_tree_set_root(root); + er_force_full_repaint(); + er_commit(); + + ERAnimConfig cfg = {0}; + cfg.type = ER_ANIM_TIMING; + cfg.duration_ms = SCALE_MS; + cfg.easing = ER_EASE_LINEAR; + s.scale = er_anim_value_create(1.0f); + er_anim_value_bind(s.scale, s.card, ER_PROP_SCALE_X); + er_anim_value_bind(s.scale, s.card, ER_PROP_SCALE_Y); + er_anim_value_animate(s.scale, 1.25f, &cfg); + return s; +} + +/** + * @brief Records the scale run into @p frames; @p fresh re-registers the image before every frame. + */ +static void record(TestCtx* t, bool fresh, uint32_t (*frames)[FB_W * FB_H]) +{ + Scene s = build_scene(t); + ERAnimValueHandle slide = er_anim_value_create(0.0f); + for (int f = 0; f < FRAMES; f++) + { + if (f == 10) + { + ERProps ip = box(8, 8, 30, 20, 0xFF30D0D0U); + er_node_set_props(s.inner, &ip); + } + if (f == 16) + { + ERAnimConfig cfg = {0}; + cfg.type = ER_ANIM_TIMING; + cfg.duration_ms = 160; + cfg.easing = ER_EASE_LINEAR; + er_anim_value_bind(slide, s.inner, ER_PROP_TRANSLATE_X); + er_anim_value_animate(slide, 24.0f, &cfg); + } + if (fresh) + er_image_load("probe", s_probe, 16, 16); + embedded_renderer_tick(FRAME_MS); + er_commit(); + memcpy(frames[f], t->fb, sizeof(frames[f])); + } +} + +static int run_equivalence(TestCtx* t) +{ + static uint32_t kept[FRAMES][FB_W * FB_H]; + static uint32_t fresh[FRAMES][FB_W * FB_H]; + record(t, false, kept); + record(t, true, fresh); + if (memcmp(kept[2], kept[FRAMES - 1], sizeof(kept[0])) == 0) + return fail("the scale animation did not change the frame"); + for (int f = 0; f < FRAMES; f++) + { + if (memcmp(kept[f], fresh[f], sizeof(kept[f])) != 0) + { + char msg[128]; + snprintf(msg, sizeof(msg), "frame %d differs between kept and fresh transform sources", f); + return fail(msg); + } + } + return EXIT_SUCCESS; +} + +static int run_probe(TestCtx* t) +{ + build_scene(t); + for (int f = 0; f < 4; f++) + { + embedded_renderer_tick(FRAME_MS); + er_commit(); + } + const uint32_t* centre = &t->fb[100 * FB_W + 120]; + if (*centre != PROBE_A) + return fail("the probe image is not at the card's centre"); + + fill_probe(PROBE_B); /* behind the registry's back: the kept source still holds A */ + embedded_renderer_tick(FRAME_MS); + er_commit(); +#if ERUI_XFORM_CACHE_SLOTS > 0 + if (*centre != PROBE_A) + return fail("a scale-only frame re-captured the card instead of reusing its kept source"); +#endif + er_image_load("probe", s_probe, 16, 16); + embedded_renderer_tick(FRAME_MS); + er_commit(); + if (*centre != PROBE_B) + return fail("a re-registered image did not invalidate the kept source"); + return EXIT_SUCCESS; +} + +#if NW > 1 +typedef struct +{ + pthread_t thread; + pthread_mutex_t mtx; + pthread_cond_t cond; + int go; +} Worker; + +static Worker s_workers[NW]; +static pthread_mutex_t s_done_mtx = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t s_done_cond = PTHREAD_COND_INITIALIZER; +static pthread_mutex_t s_serial_mtx = PTHREAD_MUTEX_INITIALIZER; +static int s_done = 0; +static int s_dispatched = 0; + +static void* worker_main(void* arg) +{ + Worker* w = arg; + for (;;) + { + pthread_mutex_lock(&w->mtx); + while (!w->go) + pthread_cond_wait(&w->cond, &w->mtx); + w->go = 0; + pthread_mutex_unlock(&w->mtx); + er_render_worker_exec((int)(w - s_workers)); + pthread_mutex_lock(&s_done_mtx); + s_done++; + pthread_cond_signal(&s_done_cond); + pthread_mutex_unlock(&s_done_mtx); + } + return NULL; +} + +static void host_dispatch(int worker, void* ctx) +{ + (void)ctx; + Worker* w = &s_workers[worker]; + pthread_mutex_lock(&w->mtx); + w->go = 1; + s_dispatched++; + pthread_cond_signal(&w->cond); + pthread_mutex_unlock(&w->mtx); +} + +static void host_sync(void* ctx) +{ + (void)ctx; + pthread_mutex_lock(&s_done_mtx); + while (s_done < s_dispatched) + pthread_cond_wait(&s_done_cond, &s_done_mtx); + s_done = s_dispatched = 0; + pthread_mutex_unlock(&s_done_mtx); +} + +static int host_worker_id(void) +{ + for (int k = 1; k < NW; k++) + if (pthread_equal(pthread_self(), s_workers[k].thread)) + return k; + return 0; +} + +static void host_lock(void* ctx) +{ + (void)ctx; + pthread_mutex_lock(&s_serial_mtx); +} + +static void host_unlock(void* ctx) +{ + (void)ctx; + pthread_mutex_unlock(&s_serial_mtx); +} + +static void install_workers(void) +{ + for (int k = 1; k < NW; k++) + { + pthread_mutex_init(&s_workers[k].mtx, NULL); + pthread_cond_init(&s_workers[k].cond, NULL); + pthread_create(&s_workers[k].thread, NULL, worker_main, &s_workers[k]); + } + static const EmbeddedRenderWorkers workers = { + NW, host_dispatch, host_sync, host_worker_id, NULL, host_lock, host_unlock}; + embedded_renderer_set_workers(&workers); +} +#endif + +int main(void) +{ + static uint32_t fb[FB_W * FB_H]; + TestCtx tc = {fb}; + EmbeddedRenderBackend be = {fill_cb, copy_cb, blend_cb, NULL, NULL, &tc}; + embedded_renderer_set_backend(&be); +#if NW > 1 + install_workers(); +#endif + if (run_equivalence(&tc) != EXIT_SUCCESS || run_probe(&tc) != EXIT_SUCCESS) + return EXIT_FAILURE; +#if NW > 1 + if (er_parallel_frames() == 0) + return fail("no frame rendered across the workers, so the shared slots went untested"); +#endif + printf("PASS: kept transform sources match fresh captures (%d workers, %d slots)\n", NW, ERUI_XFORM_CACHE_SLOTS); + return EXIT_SUCCESS; +}