Files
MrLowPr--overdrive/ps5-react/patches/embeddedReact-transform-cache.patch
2026-10-14 04:20:24 +02:00

1047 lines
39 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <pthread.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#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;
+}