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

1042 lines
46 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: View background gradients from JavaScript, dithered and damage-clipped.
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/rendering/gradient.{c,h}, engine/include/er_scene.h,
engine/scene/er_node_internal.h, engine/scene/compositor.c,
engine/tests/rendering/{CMakeLists.txt,test_gradient.c},
bridges/quickjs/native_ui_bridge.c and
bridges/quickjs/js/src/embedded-react/index.d.ts. Proposed upstream as pull
request #283.
The engine could rasterize View gradients (ERUI_GRADIENT) but the QuickJS
bridge only exposed them for <Dial>. This adds a `backgroundGradient` style
({type: 'linear', angle | to, stops} or {type: 'radial', stops}) with CSS
linear-gradient()/radial-gradient(circle farthest-corner) semantics, including
`to <corner>` lines that follow the box's aspect ratio (new gradient_corner
prop, stored in existing padding after gradient_type). The rasterizer now
interpolates stops premultiplied (as CSS does), quantizes through a per-render
8.8 fixed-point ramp with a 4x4 ordered dither keyed to framebuffer coordinates
(no 8-bit steps on long dark ramps; flat colours stay exact), assembles only the
rows and columns inside the active clip, so a small damage rect over a
full-screen gradient costs only its own pixels, and reuses rows for
axis-aligned gradients (a horizontal ramp is assembled once per dither phase).
diff --git a/bridges/quickjs/js/src/embedded-react/index.d.ts b/bridges/quickjs/js/src/embedded-react/index.d.ts
index 9823c0e..5f47744 100644
--- a/bridges/quickjs/js/src/embedded-react/index.d.ts
+++ b/bridges/quickjs/js/src/embedded-react/index.d.ts
@@ -65,6 +65,25 @@ export type TransformStyle =
| {rotateZ: string | AnimatedValue}
| {perspective: number};
+/**
+ * A View background gradient, as CSS draws one (stops interpolated premultiplied, at most 4 kept; a missing
+ * offset spaces the stops evenly). It paints instead of `backgroundColor` and is clipped to `borderRadius`.
+ */
+export type BackgroundGradient =
+ | {
+ /** linear-gradient(): `angle` in degrees (0 points to the top, 90 to the right) or a `to` side or
+ * corner; the default is `to: 'bottom'`. A corner follows the box's aspect ratio, as in CSS. */
+ type: 'linear';
+ angle?: number;
+ to?: 'top' | 'right' | 'bottom' | 'left' | 'top right' | 'bottom right' | 'bottom left' | 'top left';
+ stops: Array<{color: string; offset?: number}>;
+ }
+ | {
+ /** radial-gradient(circle farthest-corner at center, …). */
+ type: 'radial';
+ stops: Array<{color: string; offset?: number}>;
+ };
+
/**
* Value type of an untyped style entry. Styles stay open (a not-yet-declared key is not an error), and the
* union covers the object- and array-valued entries — `shadowOffset`, `transformOrigin`, `transform` — so
@@ -77,6 +96,7 @@ export type StyleValue =
| TransformStyle[]
| ShadowOffset
| TransformOrigin
+ | BackgroundGradient
| undefined;
/** A single style object. Properties mirror the React Native subset the engine supports. */
@@ -158,6 +178,7 @@ export interface ViewStyle {
// Paint.
backgroundColor?: string | AnimatedValue;
+ backgroundGradient?: BackgroundGradient;
opacity?: number | AnimatedValue;
// Border.
diff --git a/bridges/quickjs/native_ui_bridge.c b/bridges/quickjs/native_ui_bridge.c
index 8160e7a..269afb0 100644
--- a/bridges/quickjs/native_ui_bridge.c
+++ b/bridges/quickjs/native_ui_bridge.c
@@ -1170,6 +1170,7 @@ typedef enum
PROP_VALUE_START,
PROP_MIN_SPAN,
PROP_INDICATOR_GRADIENT,
+ PROP_BACKGROUND_GRADIENT,
PROP_COUNT_,
} PropId;
@@ -1293,6 +1294,7 @@ static const char* const k_prop_names[PROP_COUNT_] = {
[PROP_VALUE_START] = "valueStart",
[PROP_MIN_SPAN] = "minSpan",
[PROP_INDICATOR_GRADIENT] = "indicatorGradient",
+ [PROP_BACKGROUND_GRADIENT] = "backgroundGradient",
};
/** @brief (atom, id) pair; s_prop_atoms is sorted by atom value once, for prop_id_from_atom()'s bsearch. */
@@ -1957,28 +1959,17 @@ static void apply_track_color(JSContext* ctx, JSValueConst v, ERProps* p)
}
/**
- * @brief Reads a Dial `indicatorGradient` ({type: 'conic'|'radial', stops: [{color, offset}]}) into the
- * View-gradient fields, which ER_NODE_ARC reads as its indicator paint.
+ * @brief Reads a gradient's `stops` ([{color, offset}], at most ER_GRADIENT_MAX_STOPS) into the gradient fields.
+ *
+ * A missing offset spaces the stops evenly. As in CSS, an offset below an earlier one is raised to it, so the
+ * stops the rasterizer sees always ascend.
*
* @param[in] ctx QuickJS context.
- * @param[in] v Pre-fetched value (JS_UNDEFINED if absent).
+ * @param[in] v Gradient object.
* @param[in,out] p Props to update.
*/
-static void apply_indicator_gradient(JSContext* ctx, JSValueConst v, ERProps* p)
+static void apply_gradient_stops(JSContext* ctx, JSValueConst v, ERProps* p)
{
- if (JS_IsUndefined(v) || JS_IsNull(v) || !JS_IsObject(v))
- {
- return;
- }
- JSValue t = JS_GetPropertyStr(ctx, v, "type");
- const char* ts = JS_ToCString(ctx, t);
- if (ts)
- {
- p->gradient_type = (strcmp(ts, "radial") == 0) ? ER_GRADIENT_RADIAL : ER_GRADIENT_CONIC;
- JS_FreeCString(ctx, ts);
- }
- JS_FreeValue(ctx, t);
-
JSValue stops = JS_GetPropertyStr(ctx, v, "stops");
if (JS_IsArray(stops))
{
@@ -2002,6 +1993,10 @@ static void apply_indicator_gradient(JSContext* ctx, JSValueConst v, ERProps* p)
{
JS_ToFloat64(ctx, &o, ov);
}
+ if (n > 0 && o < p->gradient_stops[n - 1].position)
+ {
+ o = p->gradient_stops[n - 1].position;
+ }
if (to_color(ctx, cv, &c))
{
p->gradient_stops[n].color = c;
@@ -2017,6 +2012,112 @@ static void apply_indicator_gradient(JSContext* ctx, JSValueConst v, ERProps* p)
JS_FreeValue(ctx, stops);
}
+/**
+ * @brief Reads a Dial `indicatorGradient` ({type: 'conic'|'radial', stops: [{color, offset}]}) into the
+ * View-gradient fields, which ER_NODE_ARC reads as its indicator paint.
+ *
+ * @param[in] ctx QuickJS context.
+ * @param[in] v Pre-fetched value (JS_UNDEFINED if absent).
+ * @param[in,out] p Props to update.
+ */
+static void apply_indicator_gradient(JSContext* ctx, JSValueConst v, ERProps* p)
+{
+ if (JS_IsUndefined(v) || JS_IsNull(v) || !JS_IsObject(v))
+ {
+ return;
+ }
+ JSValue t = JS_GetPropertyStr(ctx, v, "type");
+ const char* ts = JS_ToCString(ctx, t);
+ if (ts)
+ {
+ p->gradient_type = (strcmp(ts, "radial") == 0) ? ER_GRADIENT_RADIAL : ER_GRADIENT_CONIC;
+ JS_FreeCString(ctx, ts);
+ }
+ JS_FreeValue(ctx, t);
+ apply_gradient_stops(ctx, v, p);
+}
+
+/** @brief CSS `to <side-or-corner>` keyword → CSS angle (sides) or ERGradientCorner (corners). */
+static const struct
+{
+ const char* to;
+ float css_angle;
+ uint8_t corner;
+} k_gradient_to[] = {
+ {"top", 0.0f, ER_GRADIENT_CORNER_NONE},
+ {"right", 90.0f, ER_GRADIENT_CORNER_NONE},
+ {"bottom", 180.0f, ER_GRADIENT_CORNER_NONE},
+ {"left", 270.0f, ER_GRADIENT_CORNER_NONE},
+ {"top right", 0.0f, ER_GRADIENT_CORNER_TOP_RIGHT},
+ {"bottom right", 0.0f, ER_GRADIENT_CORNER_BOTTOM_RIGHT},
+ {"bottom left", 0.0f, ER_GRADIENT_CORNER_BOTTOM_LEFT},
+ {"top left", 0.0f, ER_GRADIENT_CORNER_TOP_LEFT},
+};
+
+/**
+ * @brief Reads a View `backgroundGradient` into the View-gradient fields.
+ *
+ * {type: 'linear', angle?, to?, stops} follows CSS linear-gradient(): `angle` in degrees with 0 pointing to
+ * the top and 90 to the right, or `to` as a side or corner ('right', 'bottom left', …); the default is
+ * 'bottom'. {type: 'radial', stops} is radial-gradient(circle farthest-corner at center, …). Stops are
+ * interpolated premultiplied, as CSS does; at most ER_GRADIENT_MAX_STOPS are kept.
+ *
+ * @param[in] ctx QuickJS context.
+ * @param[in] v Pre-fetched value (JS_UNDEFINED if absent).
+ * @param[in,out] p Props to update.
+ */
+static void apply_background_gradient(JSContext* ctx, JSValueConst v, ERProps* p)
+{
+ if (!JS_IsObject(v))
+ {
+ return;
+ }
+ JSValue t = JS_GetPropertyStr(ctx, v, "type");
+ const char* ts = JS_ToCString(ctx, t);
+ JS_FreeValue(ctx, t);
+ if (!ts)
+ {
+ return;
+ }
+ const bool radial = strcmp(ts, "radial") == 0;
+ const bool linear = strcmp(ts, "linear") == 0;
+ JS_FreeCString(ctx, ts);
+ if (!radial && !linear)
+ {
+ return;
+ }
+ p->gradient_type = radial ? ER_GRADIENT_RADIAL : ER_GRADIENT_LINEAR;
+
+ float css_angle = 180.0f;
+ JSValue av = JS_GetPropertyStr(ctx, v, "angle");
+ JSValue tov = JS_GetPropertyStr(ctx, v, "to");
+ double a;
+ if (JS_IsNumber(av) && JS_ToFloat64(ctx, &a, av) == 0)
+ {
+ css_angle = (float)a;
+ }
+ else if (JS_IsString(tov))
+ {
+ const char* to = JS_ToCString(ctx, tov);
+ for (size_t i = 0; to && i < sizeof(k_gradient_to) / sizeof(k_gradient_to[0]); i++)
+ {
+ if (strcmp(to, k_gradient_to[i].to) == 0)
+ {
+ css_angle = k_gradient_to[i].css_angle;
+ p->gradient_corner = k_gradient_to[i].corner;
+ break;
+ }
+ }
+ JS_FreeCString(ctx, to);
+ }
+ JS_FreeValue(ctx, av);
+ JS_FreeValue(ctx, tov);
+ /* The engine measures from top→bottom; CSS's 0deg points to the top. */
+ p->gradient_angle = 180.0f - css_angle;
+
+ apply_gradient_stops(ctx, v, p);
+}
+
/**
* @brief Reads obj's own enumerable string keys into s_prop_slots.
*
@@ -2155,6 +2256,7 @@ static void apply_props(JSContext* ctx, ERNode* node, JSValueConst obj)
/* View visual. */
ER_COL(PROP_BACKGROUND_COLOR, background_color);
+ apply_background_gradient(ctx, s_prop_slots[PROP_BACKGROUND_GRADIENT], &p);
apply_opacity(ctx, s_prop_slots[PROP_OPACITY], &p);
ER_DIM(PROP_BORDER_RADIUS, border_radius);
ER_DIM(PROP_BORDER_TOP_LEFT_RADIUS, border_top_left_radius);
diff --git a/engine/include/er_scene.h b/engine/include/er_scene.h
index ff71290..4fba433 100644
--- a/engine/include/er_scene.h
+++ b/engine/include/er_scene.h
@@ -416,6 +416,22 @@ extern "C"
ER_GRADIENT_CONIC = 3, /**< Conic/angular gradient (VECTOR fills only; requires ERUI_GRADIENT_CONIC). */
} ERGradientType;
+ /**
+ * @brief CSS `to <corner>` direction for a View-background linear gradient.
+ *
+ * A corner direction depends on the box's aspect ratio (the gradient line is perpendicular to the
+ * diagonal joining the two neighbouring corners, as CSS linear-gradient defines it), so it is resolved
+ * at render time from the laid-out size. ER_GRADIENT_CORNER_NONE uses gradient_angle instead.
+ */
+ typedef enum
+ {
+ ER_GRADIENT_CORNER_NONE = 0, /**< Direction from gradient_angle (default). */
+ ER_GRADIENT_CORNER_TOP_RIGHT = 1, /**< `to top right`. */
+ ER_GRADIENT_CORNER_BOTTOM_RIGHT = 2, /**< `to bottom right`. */
+ ER_GRADIENT_CORNER_BOTTOM_LEFT = 3, /**< `to bottom left`. */
+ ER_GRADIENT_CORNER_TOP_LEFT = 4, /**< `to top left`. */
+ } ERGradientCorner;
+
/**
* @brief One color stop in a gradient.
*
@@ -804,6 +820,7 @@ extern "C"
/* --- Gradient (View-family; requires ERUI_GRADIENT at build time) --- */
uint8_t gradient_type; /**< ERGradientType — default ER_GRADIENT_NONE. */
+ uint8_t gradient_corner; /**< ERGradientCorner; overrides gradient_angle for a LINEAR gradient. */
float gradient_angle; /**< Angle in degrees: 0 = top→bottom, 90 = left→right. */
uint8_t gradient_stop_count; /**< Number of valid entries in gradient_stops [0–ER_GRADIENT_MAX_STOPS]. */
ERGradientStop gradient_stops[ER_GRADIENT_MAX_STOPS]; /**< Color stops in order of ascending position. */
diff --git a/engine/rendering/gradient.c b/engine/rendering/gradient.c
index 3316dd7..faa498e 100644
--- a/engine/rendering/gradient.c
+++ b/engine/rendering/gradient.c
@@ -117,6 +117,160 @@ uint32_t er_gradient_premul(uint32_t sa)
#if ERUI_GRADIENT
+/*----------------------------------------------------------------------------------------------------------------------
+ - Colour ramp
+ ---------------------------------------------------------------------------------------------------------------------*/
+
+/** @brief Ramp entries cap: one per pixel of the gradient line up to here (8 KB per render worker). */
+#define GRAD_LUT_MAX 1024
+
+/* The ramp is premultiplied with 8 fractional bits per channel, so the ordered dither below can spread
+ * the sub-level remainder over neighbouring pixels; without it a dark gradient over 1000+ px shows as 8-bit
+ * steps tens of pixels wide. Each entry packs a, r, g, b as 16-bit lanes of one word (a in the top lane),
+ * so a pixel is one load, one add and a byte gather. */
+static uint64_t s_grad_lut_pool[ERUI_RENDER_WORKERS][GRAD_LUT_MAX];
+
+/** @brief 4x4 Bayer matrix; a threshold is (k * 16 + 8) / 256 of a level, so flat colours stay exact. */
+static const uint8_t k_bayer4[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
+
+/** @brief Broadcasts a dither threshold to all four lanes of a ramp entry. */
+#define GRAD_LANES 0x0001000100010001ULL
+
+/**
+ * @brief Premultiplies a straight-alpha ARGB8888 colour into float channels (alpha, r, g, b).
+ *
+ * @param[in] sa Straight-alpha ARGB8888 colour.
+ * @param[out] out Premultiplied channels in [0, 255].
+ */
+static void premul_f(uint32_t sa, float out[4])
+{
+ const float a = (float)((sa >> 24) & 0xFFu);
+ out[0] = a;
+ out[1] = (float)((sa >> 16) & 0xFFu) * a / 255.0f;
+ out[2] = (float)((sa >> 8) & 0xFFu) * a / 255.0f;
+ out[3] = (float)(sa & 0xFFu) * a / 255.0f;
+}
+
+/**
+ * @brief Builds the calling worker's colour ramp for a View gradient.
+ *
+ * Stops are interpolated in premultiplied space, as CSS gradients are: a fade to `transparent` keeps the
+ * colour it fades from instead of darkening through transparent black.
+ *
+ * @param[in] vp View props (gradient_stop_count >= 2, stops ascending).
+ * @param[in] extent Length of the gradient line in pixels; sizes the ramp (1 entry per pixel, capped).
+ *
+ * @return Number of entries built (2..GRAD_LUT_MAX); entry i is the colour at t = i / (n - 1).
+ */
+static int build_lut(const ERViewProps* vp, float extent)
+{
+ int n = (int)extent + 1;
+ if (n < 2)
+ n = 2;
+ if (n > GRAD_LUT_MAX)
+ n = GRAD_LUT_MAX;
+
+ uint64_t* lut = s_grad_lut_pool[er_render_worker_id()];
+ const ERGradientStop* st = vp->gradient_stops;
+ const int last = (int)vp->gradient_stop_count - 1;
+ int seg = 0;
+ for (int i = 0; i < n; i++)
+ {
+ const float t = (float)i / (float)(n - 1);
+ float c[4];
+ if (t <= st[0].position)
+ premul_f(st[0].color, c);
+ else if (t >= st[last].position)
+ premul_f(st[last].color, c);
+ else
+ {
+ while (t > st[seg + 1].position)
+ seg++;
+ float c0[4], c1[4];
+ premul_f(st[seg].color, c0);
+ premul_f(st[seg + 1].color, c1);
+ const float span = st[seg + 1].position - st[seg].position;
+ const float lt = (span > 0.0f) ? (t - st[seg].position) / span : 1.0f;
+ for (int k = 0; k < 4; k++)
+ c[k] = c0[k] + (c1[k] - c0[k]) * lt;
+ }
+ const uint64_t a = (uint64_t)(c[0] * 256.0f + 0.5f);
+ uint64_t e = a << 48;
+ for (int k = 1; k < 4; k++)
+ {
+ const uint64_t v = (uint64_t)(c[k] * 256.0f + 0.5f);
+ /* Keep colour <= alpha so every dithered pixel is a valid premultiplied value. */
+ e |= (v < a ? v : a) << (48 - 16 * k);
+ }
+ lut[i] = e;
+ }
+ return n;
+}
+
+/**
+ * @brief Quantizes a dithered ramp entry (entry + threshold * GRAD_LANES) to premultiplied ARGB8888.
+ *
+ * Thresholds are in 1/256 of a level, from k_bayer4. The same threshold on every channel preserves
+ * colour <= alpha, and no lane carries: each stays below 65536.
+ *
+ * @param[in] e Ramp entry plus its broadcast threshold (8.8 fixed point per 16-bit lane).
+ * @return Premultiplied ARGB8888 pixel.
+ */
+static inline uint32_t dither_px(uint64_t e)
+{
+ const uint64_t v = e >> 8;
+ return (uint32_t)((v & 0xFFu) | ((v >> 8) & 0xFF00u) | ((v >> 16) & 0xFF0000u) | ((v >> 24) & 0xFF000000u));
+}
+
+/*----------------------------------------------------------------------------------------------------------------------
+ - Damage window
+ ---------------------------------------------------------------------------------------------------------------------*/
+
+/**
+ * @brief The part of a gradient box the current clip lets through, in box coordinates.
+ *
+ * A small repaint over a full-screen gradient would otherwise rebuild every row and column of it only to
+ * have er_blit_blend scissor them away.
+ */
+typedef struct
+{
+ int c0; /**< First column. */
+ int c1; /**< One past the last column (never beyond the row buffer). */
+ int r0; /**< First row. */
+ int r1; /**< One past the last row. */
+} GradWindow;
+
+/**
+ * @brief Intersects the box with the active clip rect.
+ *
+ * @param[out] win Receives the visible window.
+ * @param[in] x Box left edge in framebuffer pixels.
+ * @param[in] y Box top edge in framebuffer pixels.
+ * @param[in] capped_w Box width capped to the row buffer.
+ * @param[in] h Box height.
+ * @return False when nothing of the box is visible.
+ */
+static bool window_init(GradWindow* win, int x, int y, int capped_w, int h)
+{
+ win->c0 = 0;
+ win->c1 = capped_w;
+ win->r0 = 0;
+ win->r1 = h;
+ int cx, cy, cw, ch;
+ if (er_get_clip_rect(&cx, &cy, &cw, &ch))
+ {
+ if (cx - x > win->c0)
+ win->c0 = cx - x;
+ if (cx + cw - x < win->c1)
+ win->c1 = cx + cw - x;
+ if (cy - y > win->r0)
+ win->r0 = cy - y;
+ if (cy + ch - y < win->r1)
+ win->r1 = cy + ch - y;
+ }
+ return win->c0 < win->c1 && win->r0 < win->r1;
+}
+
/*----------------------------------------------------------------------------------------------------------------------
- Rounded-corner masking
---------------------------------------------------------------------------------------------------------------------*/
@@ -162,33 +316,35 @@ static void mask_init(GradMask* m, const ERViewProps* vp, int w, int h)
}
/**
- * @brief Blits one assembled gradient row, clipped to the rounded-rect silhouette.
+ * @brief Blits the windowed part of one assembled gradient row, clipped to the rounded-rect silhouette.
*
* Rows clear of the corner arcs blit whole; rows the arcs cut into blit only their covered span, then
* fade the anti-aliased fringe pixels beside it (matching er_rrect_fill_corners' fringe walk, so the
* gradient's corner is the same shape a solid background would have painted).
*
- * @param[in] m Silhouette to clip to.
- * @param[in] row Assembled premultiplied ARGB8888 row, @p capped_w pixels wide.
- * @param[in] x Destination left edge in framebuffer pixels.
- * @param[in] y Destination row in framebuffer pixels.
- * @param[in] row_idx Row index within the box, 0 = top.
- * @param[in] capped_w Assembled width (may be less than m->w on very wide boxes).
+ * @param[in] m Silhouette to clip to.
+ * @param[in] win Visible window; only row[win->c0 .. win->c1) was assembled.
+ * @param[in] row Assembled premultiplied ARGB8888 row, indexed by box column.
+ * @param[in] x Destination left edge of the box in framebuffer pixels.
+ * @param[in] y Destination row in framebuffer pixels.
+ * @param[in] row_idx Row index within the box, 0 = top.
*/
-static void mask_blit_row(const GradMask* m, const uint32_t* row, int x, int y, int row_idx, int capped_w)
+static void mask_blit_row(const GradMask* m, const GradWindow* win, const uint32_t* row, int x, int y, int row_idx)
{
if (!m->rounded)
{
- er_blit_blend(row, capped_w * (int)sizeof(uint32_t), 255, x, y, capped_w, 1);
+ er_blit_blend(&row[win->c0], (win->c1 - win->c0) * (int)sizeof(uint32_t), 255, x + win->c0, y,
+ win->c1 - win->c0, 1);
return;
}
ERRRectRow rr;
er_rrect_row(m->w, m->h, m->r_tl, m->r_tr, m->r_br, m->r_bl, row_idx, &rr);
- const int x1 = (rr.x1 < capped_w) ? rr.x1 : capped_w;
- if (x1 > rr.x0)
- er_blit_blend(&row[rr.x0], (x1 - rr.x0) * (int)sizeof(uint32_t), 255, x + rr.x0, y, x1 - rr.x0, 1);
+ const int x0 = (rr.x0 > win->c0) ? rr.x0 : win->c0;
+ const int x1 = (rr.x1 < win->c1) ? rr.x1 : win->c1;
+ if (x1 > x0)
+ er_blit_blend(&row[x0], (x1 - x0) * (int)sizeof(uint32_t), 255, x + x0, y, x1 - x0, 1);
#if ERUI_BORDER_AA
/* Fringe pixels take the gradient colour at their own column, faded by the arc's coverage. */
@@ -198,7 +354,7 @@ static void mask_blit_row(const GradMask* m, const uint32_t* row, int x, int y,
if (cov <= 0.0f)
break;
const int ax = rr.x0 - 1 - k;
- if (cov < 1.0f && ax >= 0 && ax < capped_w)
+ if (cov < 1.0f && ax >= win->c0 && ax < win->c1)
{
const uint32_t p = er_px_scale_premul(row[ax], (uint32_t)(cov * 255.0f + 0.5f));
er_blit_blend(&p, (int)sizeof(uint32_t), 255, x + ax, y, 1, 1);
@@ -210,7 +366,7 @@ static void mask_blit_row(const GradMask* m, const uint32_t* row, int x, int y,
if (cov <= 0.0f)
break;
const int ax = rr.x1 + k;
- if (cov < 1.0f && ax >= 0 && ax < capped_w && ax < m->w)
+ if (cov < 1.0f && ax >= win->c0 && ax < win->c1 && ax < m->w)
{
const uint32_t p = er_px_scale_premul(row[ax], (uint32_t)(cov * 255.0f + 0.5f));
er_blit_blend(&p, (int)sizeof(uint32_t), 255, x + ax, y, 1, 1);
@@ -220,15 +376,98 @@ static void mask_blit_row(const GradMask* m, const uint32_t* row, int x, int y,
}
/**
- * @brief Renders a linear gradient into a rectangular framebuffer region.
+ * @brief Assembles row[c0 .. c1) of a linear gradient from the ramp.
*
- * The gradient angle is measured in degrees clockwise from the top: 0° runs top→bottom,
- * 90° runs left→right. The four corner projections onto the gradient direction vector are
- * used to normalise the per-pixel parameter t to [0.0–1.0] regardless of angle.
+ * @param[out] buf Row buffer, indexed by box column.
+ * @param[in] lut Ramp from build_lut.
+ * @param[in] n Ramp entries.
+ * @param[in] f Ramp index at column c0, 16.16 fixed point.
+ * @param[in] step Ramp index change per column, 16.16 fixed point.
+ * @param[in] bayer The row's k_bayer4 line.
+ * @param[in] x Box left edge in framebuffer pixels (keys the dither column).
+ * @param[in] c0 First column.
+ * @param[in] c1 One past the last column.
+ */
+static void assemble_row(uint32_t* buf, const uint64_t* lut, int n, int32_t f, int32_t step, const uint8_t* bayer,
+ int x, int c0, int c1)
+{
+ /* The dither threshold cycles every 4 columns: hoist the four lane-broadcast thresholds. */
+ uint64_t thr[4];
+ for (int k = 0; k < 4; k++)
+ thr[k] = ((uint64_t)bayer[(x + c0 + k) & 3] * 16u + 8u) * GRAD_LANES;
+
+ /* A ramp that runs purely along y is one colour per row: only the 4-pixel dither cycle varies. */
+ const int end = (step == 0 && c1 - c0 > 4) ? c0 + 4 : c1;
+ for (int col = c0; col < end; col++, f += step)
+ {
+ int idx = (f + 32768) >> 16;
+ if (idx < 0)
+ idx = 0;
+ else if (idx >= n)
+ idx = n - 1;
+ buf[col] = dither_px(lut[idx] + thr[(col - c0) & 3]);
+ }
+ for (int col = end; col < c1; col++)
+ buf[col] = buf[col - 4];
+}
+
+/**
+ * @brief Resolves a linear gradient's screen-space unit direction (y down).
*
- * Each row is computed into grow() and flushed via er_blit_blend (premultiplied).
+ * gradient_angle is degrees clockwise from the top with 0° running top→bottom and 90° left→right (CSS's
+ * `<angle>` is this plus 180°). A gradient_corner instead aims the line as CSS `to <corner>` does:
+ * perpendicular to the diagonal joining the two neighbouring corners, which depends on the box's aspect.
*
- * @param[in] vp View props (gradient_angle, gradient_stop_count, gradient_stops).
+ * @param[in] vp View props (gradient_corner, gradient_angle).
+ * @param[in] w Box width in pixels.
+ * @param[in] h Box height in pixels.
+ * @param[out] ddx Direction x component.
+ * @param[out] ddy Direction y component.
+ */
+static void linear_direction(const ERViewProps* vp, int w, int h, float* ddx, float* ddy)
+{
+ const float len = sqrtf((float)w * (float)w + (float)h * (float)h);
+ const float sx = (float)h / len, sy = (float)w / len;
+ switch (vp->gradient_corner)
+ {
+ case ER_GRADIENT_CORNER_TOP_RIGHT:
+ *ddx = sx;
+ *ddy = -sy;
+ return;
+ case ER_GRADIENT_CORNER_BOTTOM_RIGHT:
+ *ddx = sx;
+ *ddy = sy;
+ return;
+ case ER_GRADIENT_CORNER_BOTTOM_LEFT:
+ *ddx = -sx;
+ *ddy = sy;
+ return;
+ case ER_GRADIENT_CORNER_TOP_LEFT:
+ *ddx = -sx;
+ *ddy = -sy;
+ return;
+ default:
+ /* Snapped so a side direction is exactly axis-aligned (cosf(90°) is not 0): render_linear
+ * reuses rows only when the ramp runs purely along x. */
+ *ddx = sinf(vp->gradient_angle * ER_DEG2RAD);
+ *ddy = cosf(vp->gradient_angle * ER_DEG2RAD);
+ if (fabsf(*ddx) < 1e-6f)
+ *ddx = 0.0f;
+ if (fabsf(*ddy) < 1e-6f)
+ *ddy = 0.0f;
+ return;
+ }
+}
+
+/**
+ * @brief Renders a linear gradient into a rectangular framebuffer region.
+ *
+ * The four corner projections onto the direction vector normalise the per-pixel parameter t to
+ * [0.0–1.0] regardless of angle — the same gradient line length CSS uses — with the first and last pixel
+ * centres landing exactly on t = 0 and t = 1. Only the rows and columns inside the active clip are
+ * assembled; each row is flushed via er_blit_blend (premultiplied).
+ *
+ * @param[in] vp View props (gradient_corner, gradient_angle, gradient_stop_count, gradient_stops).
* @param[in] x Destination left edge in framebuffer pixels.
* @param[in] y Destination top edge in framebuffer pixels.
* @param[in] w Destination width in pixels.
@@ -239,52 +478,52 @@ static void render_linear(const ERViewProps* vp, int x, int y, int w, int h)
if (vp->gradient_stop_count < 2 || w <= 0 || h <= 0)
return;
+ const int capped_w = (w <= ERUI_MAX_IMG_ROW_PIXELS) ? w : ERUI_MAX_IMG_ROW_PIXELS;
+ GradWindow win;
+ if (!window_init(&win, x, y, capped_w, h))
+ return;
+
GradMask mask;
mask_init(&mask, vp, w, h);
- const float angle_rad = vp->gradient_angle * ER_DEG2RAD;
- const float ddx = sinf(angle_rad);
- const float ddy = cosf(angle_rad);
+ float ddx, ddy;
+ linear_direction(vp, w, h, &ddx, &ddy);
/* Project all four corners onto the direction vector to find the full extent. */
const float wf = (float)(w - 1);
const float hf = (float)(h - 1);
- const float p00 = 0.0f;
const float p10 = wf * ddx;
const float p01 = hf * ddy;
const float p11 = wf * ddx + hf * ddy;
- float min_p = p00, max_p = p00;
- if (p10 < min_p)
- min_p = p10;
- if (p10 > max_p)
- max_p = p10;
- if (p01 < min_p)
- min_p = p01;
- if (p01 > max_p)
- max_p = p01;
- if (p11 < min_p)
- min_p = p11;
- if (p11 > max_p)
- max_p = p11;
+ const float min_p = fminf(fminf(0.0f, p10), fminf(p01, p11));
+ const float max_p = fmaxf(fmaxf(0.0f, p10), fmaxf(p01, p11));
const float span = max_p - min_p;
- const float inv_span = (span > 1e-6f) ? 1.0f / span : 0.0f;
- const int capped_w = (w <= ERUI_MAX_IMG_ROW_PIXELS) ? w : ERUI_MAX_IMG_ROW_PIXELS;
-
- for (int row = 0; row < h; row++)
+ const int n = build_lut(vp, span);
+ const uint64_t* lut = s_grad_lut_pool[er_render_worker_id()];
+ /* Ramp index per pixel step, in 16.16 fixed point. */
+ const float to_idx = (span > 1e-6f) ? (float)(n - 1) / span : 0.0f;
+ const int32_t step = (int32_t)lrintf(ddx * to_idx * 65536.0f);
+
+ uint32_t* buf = grow();
+ if (ddy == 0.0f)
{
- const float row_base = (float)row * ddy;
- for (int col = 0; col < capped_w; col++)
+ /* A ramp that runs purely along x repeats every 4 rows (the dither cycle): assemble each phase
+ * once and blit it to all of its rows. The blits never overlap, so their order does not matter. */
+ const int32_t f = (int32_t)lrintf(((float)win.c0 * ddx - min_p) * to_idx * 65536.0f);
+ for (int first = win.r0; first < win.r0 + 4 && first < win.r1; first++)
{
- float t = ((float)col * ddx + row_base - min_p) * inv_span;
- if (t < 0.0f)
- t = 0.0f;
- if (t > 1.0f)
- t = 1.0f;
- grow()[col] =
- er_gradient_premul(er_gradient_eval_stops(vp->gradient_stops, (int)vp->gradient_stop_count, t));
+ assemble_row(buf, lut, n, f, step, k_bayer4[(y + first) & 3], x, win.c0, win.c1);
+ for (int row = first; row < win.r1; row += 4)
+ mask_blit_row(&mask, &win, buf, x, y + row, row);
}
- mask_blit_row(&mask, grow(), x, y + row, row, capped_w);
+ return;
+ }
+ for (int row = win.r0; row < win.r1; row++)
+ {
+ const int32_t f = (int32_t)lrintf(((float)win.c0 * ddx + (float)row * ddy - min_p) * to_idx * 65536.0f);
+ assemble_row(buf, lut, n, f, step, k_bayer4[(y + row) & 3], x, win.c0, win.c1);
+ mask_blit_row(&mask, &win, buf, x, y + row, row);
}
}
@@ -293,11 +532,10 @@ static void render_linear(const ERViewProps* vp, int x, int y, int w, int h)
/**
* @brief Renders a radial gradient into a rectangular framebuffer region.
*
- * The gradient origin is the rectangle centre. The radius is the Euclidean distance from
- * the centre to the farthest corner, so t = 1.0 is reached at exactly the corner pixels and
- * the full stop range is always visible inside the rect.
- *
- * Each row is computed into grow() and flushed via er_blit_blend (premultiplied).
+ * A circle centred on the rectangle whose radius reaches the farthest corner pixel — CSS
+ * `radial-gradient(circle farthest-corner at center, …)` — so the full stop range is always visible
+ * inside the rect. Only the rows and columns inside the active clip are assembled; each row is
+ * flushed via er_blit_blend (premultiplied).
*
* @param[in] vp View props (gradient_stop_count, gradient_stops).
* @param[in] x Destination left edge in framebuffer pixels.
@@ -310,28 +548,35 @@ static void render_radial(const ERViewProps* vp, int x, int y, int w, int h)
if (vp->gradient_stop_count < 2 || w <= 0 || h <= 0)
return;
+ const int capped_w = (w <= ERUI_MAX_IMG_ROW_PIXELS) ? w : ERUI_MAX_IMG_ROW_PIXELS;
+ GradWindow win;
+ if (!window_init(&win, x, y, capped_w, h))
+ return;
+
GradMask mask;
mask_init(&mask, vp, w, h);
const float cx = (float)(w - 1) * 0.5f;
const float cy = (float)(h - 1) * 0.5f;
const float r = sqrtf(cx * cx + cy * cy);
- const float inv_r = (r > 1e-6f) ? 1.0f / r : 0.0f;
- const int capped_w = (w <= ERUI_MAX_IMG_ROW_PIXELS) ? w : ERUI_MAX_IMG_ROW_PIXELS;
+ const int n = build_lut(vp, r);
+ const uint64_t* lut = s_grad_lut_pool[er_render_worker_id()];
+ const float to_idx = (r > 1e-6f) ? (float)(n - 1) / r : 0.0f;
- for (int row = 0; row < h; row++)
+ uint32_t* buf = grow();
+ for (int row = win.r0; row < win.r1; row++)
{
+ const uint8_t* bayer = k_bayer4[(y + row) & 3];
const float dy2 = ((float)row - cy) * ((float)row - cy);
- for (int col = 0; col < capped_w; col++)
+ for (int col = win.c0; col < win.c1; col++)
{
- const float dx2 = ((float)col - cx) * ((float)col - cx);
- float t = sqrtf(dx2 + dy2) * inv_r;
- if (t > 1.0f)
- t = 1.0f;
- grow()[col] =
- er_gradient_premul(er_gradient_eval_stops(vp->gradient_stops, (int)vp->gradient_stop_count, t));
+ const float dx = (float)col - cx;
+ int idx = (int)(sqrtf(dx * dx + dy2) * to_idx + 0.5f);
+ if (idx >= n)
+ idx = n - 1;
+ buf[col] = dither_px(lut[idx] + ((uint64_t)bayer[(x + col) & 3] * 16u + 8u) * GRAD_LANES);
}
- mask_blit_row(&mask, grow(), x, y + row, row, capped_w);
+ mask_blit_row(&mask, &win, buf, x, y + row, row);
}
}
diff --git a/engine/rendering/gradient.h b/engine/rendering/gradient.h
index 871079a..eb5be6f 100644
--- a/engine/rendering/gradient.h
+++ b/engine/rendering/gradient.h
@@ -28,6 +28,10 @@
*
* Dispatches to the linear or radial rasterizer based on vp->gradient_type. Each rasterizer
* writes one row of premultiplied ARGB8888 pixels at a time and flushes it via er_blit_blend.
+ * Stops are interpolated in premultiplied space (as CSS does) and quantized with a 4x4 ordered
+ * dither keyed to framebuffer coordinates, so long, low-contrast ramps show no 8-bit steps and a
+ * partial repaint matches the full one. Only the rows and columns inside the active clip rect are
+ * assembled, so a small damage rect over a large gradient costs only its own pixels.
*
* The fill is clipped to the node's rounded-rect silhouette, resolved from vp's border_radius and
* per-corner overrides through the same er_rrect_clamp_radii / er_rrect_row geometry the background
diff --git a/engine/scene/compositor.c b/engine/scene/compositor.c
index b9fe937..457bcf9 100644
--- a/engine/scene/compositor.c
+++ b/engine/scene/compositor.c
@@ -3300,6 +3300,7 @@ static void copy_view_shadow_and_gradient(ERNode* node, const ERProps* props)
node->props.view.shadow_radius = props->shadow_radius;
node->props.view.elevation = props->elevation;
node->props.view.gradient_type = props->gradient_type;
+ node->props.view.gradient_corner = props->gradient_corner;
node->props.view.gradient_angle = props->gradient_angle;
node->props.view.gradient_stop_count = props->gradient_stop_count;
for (int gi = 0; gi < ER_GRADIENT_MAX_STOPS; gi++)
diff --git a/engine/scene/er_node_internal.h b/engine/scene/er_node_internal.h
index b38cdfe..31ae5f1 100644
--- a/engine/scene/er_node_internal.h
+++ b/engine/scene/er_node_internal.h
@@ -223,6 +223,7 @@ typedef struct
/* Gradient (requires ERUI_GRADIENT) */
uint8_t gradient_type; /**< ERGradientType. */
+ uint8_t gradient_corner; /**< ERGradientCorner (overrides gradient_angle). */
float gradient_angle; /**< Angle in degrees (0 = top→bottom, 90 = left→right). */
uint8_t gradient_stop_count; /**< Number of active stops [0–ER_GRADIENT_MAX_STOPS]. */
ERGradientStop gradient_stops[ER_GRADIENT_MAX_STOPS]; /**< Color stops in order of ascending position. */
diff --git a/engine/tests/rendering/CMakeLists.txt b/engine/tests/rendering/CMakeLists.txt
index ce62ddd..382fc98 100644
--- a/engine/tests/rendering/CMakeLists.txt
+++ b/engine/tests/rendering/CMakeLists.txt
@@ -105,6 +105,10 @@ add_test(NAME rendering_components COMMAND test_components)
add_executable(test_gradient test_gradient.c)
target_link_libraries(test_gradient PRIVATE embedded-react)
+# test_gradient needs engine/core, engine/rendering and engine/scene (er_push_clip_rect, er_gradient_render and the
+# node's ERViewProps) to repaint one gradient under a damage clip and compare it with the full frame.
+target_include_directories(test_gradient PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../core ${CMAKE_CURRENT_SOURCE_DIR}/../../rendering
+ ${CMAKE_CURRENT_SOURCE_DIR}/../../scene)
add_test(NAME rendering_gradient COMMAND test_gradient)
add_executable(test_damage_rects test_damage_rects.c)
diff --git a/engine/tests/rendering/test_gradient.c b/engine/tests/rendering/test_gradient.c
index 410859a..378cf70 100644
--- a/engine/tests/rendering/test_gradient.c
+++ b/engine/tests/rendering/test_gradient.c
@@ -15,7 +15,9 @@
*/
#include "er_scene.h"
+#include "gradient.h"
#include "native_renderer.h"
+#include "renderer_internal.h"
#include <math.h>
#include <stdint.h>
#include <stdio.h>
@@ -198,6 +200,10 @@ static int fail(const char* msg)
* - Linear diagonal gradient (45°): corner pixels match stop endpoints.
* - Radial gradient: center pixel = stop[0], corner pixel ≈ stop[1].
* - Degenerate gradient (stop_count < 2): no-op; background_color used.
+ * - Ordered dither: a low-contrast ramp averages to its exact value, flat colours stay exact.
+ * - Premultiplied interpolation: a fade to transparent keeps its colour.
+ * - CSS `to <corner>`: the neighbouring corners sit on the 50% line for a non-square box.
+ * - Damage clip: a clipped repaint reproduces the full frame's pixels inside the clip.
* - Bilinear vs nearest-neighbor scale: smoke-test showing render completes without crash.
*
* @return EXIT_SUCCESS on pass, EXIT_FAILURE on the first failed assertion.
@@ -535,6 +541,186 @@ int main(void)
return fail("per-corner radius: the square bottom-right corner was clipped");
}
+ /* -----------------------------------------------------------------------
+ * Test 8: ordered dither.
+ *
+ * A 4-level ramp over 64 px is 16 px per 8-bit step without dithering. With it, every 4x4 block
+ * averages to the exact ramp value at its centre (within a tenth of a level), and pixels only ever
+ * take the two levels around that value.
+ * ----------------------------------------------------------------------- */
+ {
+ ERProps p;
+ memset(&p, 0, sizeof(p));
+ p.left = p.top = p.right = p.bottom = ER_LAYOUT_AUTO;
+ p.width = FB_W;
+ p.height = FB_H;
+ p.opacity = 255;
+ p.gradient_type = ER_GRADIENT_LINEAR;
+ p.gradient_angle = 90.0f;
+ p.gradient_stop_count = 2;
+ p.gradient_stops[0].color = 0xFF101010;
+ p.gradient_stops[0].position = 0.0f;
+ p.gradient_stops[1].color = 0xFF141414;
+ p.gradient_stops[1].position = 1.0f;
+ er_node_set_props(root, &p);
+
+ memset(tc.fb, 0, sizeof(tc.fb));
+ er_commit();
+
+ if (px(&tc, 0, 5) != 0xFF101010 || px(&tc, FB_W - 1, 9) != 0xFF141414)
+ return fail("dither: the end stops must stay exact");
+ for (int bx = 0; bx + 4 <= FB_W; bx += 4)
+ {
+ int sum = 0;
+ for (int yy = 8; yy < 12; yy++)
+ {
+ for (int xx = bx; xx < bx + 4; xx++)
+ {
+ const int r = (int)((px(&tc, xx, yy) >> 16) & 0xFFu);
+ const float exact = 16.0f + 4.0f * (float)xx / (float)(FB_W - 1);
+ if (r < (int)exact || r > (int)exact + 1)
+ return fail("dither: a pixel left the two levels around its exact value");
+ sum += r;
+ }
+ }
+ const float want = 16.0f + 4.0f * ((float)bx + 1.5f) / (float)(FB_W - 1);
+ if (fabsf((float)sum / 16.0f - want) > 0.1f)
+ {
+ printf(" block x=%d: mean %.3f, exact %.3f\n", bx, (double)((float)sum / 16.0f), (double)want);
+ return fail("dither: a 4x4 block does not average to the exact ramp value");
+ }
+ }
+ }
+
+ /* -----------------------------------------------------------------------
+ * Test 9: premultiplied interpolation — white fading to transparent black stays white.
+ *
+ * CSS interpolates stops premultiplied, so the midpoint is half-covered WHITE (r == a), not the
+ * grey that blending straight-alpha channels toward transparent black produces.
+ * ----------------------------------------------------------------------- */
+ {
+ ERProps p;
+ memset(&p, 0, sizeof(p));
+ p.left = p.top = p.right = p.bottom = ER_LAYOUT_AUTO;
+ p.width = FB_W;
+ p.height = FB_H;
+ p.opacity = 255;
+ p.gradient_type = ER_GRADIENT_LINEAR;
+ p.gradient_angle = 0.0f;
+ p.gradient_stop_count = 2;
+ p.gradient_stops[0].color = 0xFFFFFFFF;
+ p.gradient_stops[0].position = 0.0f;
+ p.gradient_stops[1].color = 0x00000000;
+ p.gradient_stops[1].position = 1.0f;
+ er_node_set_props(root, &p);
+
+ memset(tc.fb, 0, sizeof(tc.fb));
+ er_commit();
+
+ for (int yy = 0; yy < FB_H; yy++)
+ {
+ const uint32_t c = px(&tc, 7, yy);
+ if (((c >> 16) & 0xFFu) != ((c >> 24) & 0xFFu))
+ return fail("premultiplied: a fade to transparent darkened its colour");
+ }
+ const int mid_a = (int)((px(&tc, 7, FB_H / 2) >> 24) & 0xFFu);
+ if (mid_a < 120 || mid_a > 135)
+ return fail("premultiplied: the midpoint is not half covered");
+ }
+
+ /* -----------------------------------------------------------------------
+ * Test 10: CSS `to bottom right` on a 2:1 box.
+ *
+ * The gradient line is perpendicular to the top-right/bottom-left diagonal, so those two corners
+ * share the 50% colour; a fixed 135° angle would give them different colours on a non-square box.
+ * ----------------------------------------------------------------------- */
+ {
+ ERProps p;
+ memset(&p, 0, sizeof(p));
+ p.left = p.top = p.right = p.bottom = ER_LAYOUT_AUTO;
+ p.width = FB_W;
+ p.height = FB_H / 2;
+ p.opacity = 255;
+ p.gradient_type = ER_GRADIENT_LINEAR;
+ p.gradient_corner = ER_GRADIENT_CORNER_BOTTOM_RIGHT;
+ p.gradient_stop_count = 2;
+ p.gradient_stops[0].color = 0xFF000000;
+ p.gradient_stops[0].position = 0.0f;
+ p.gradient_stops[1].color = 0xFFFF0000;
+ p.gradient_stops[1].position = 1.0f;
+ er_node_set_props(root, &p);
+
+ memset(tc.fb, 0, sizeof(tc.fb));
+ er_commit();
+
+ const int tl = (int)((px(&tc, 0, 0) >> 16) & 0xFFu);
+ const int br = (int)((px(&tc, FB_W - 1, FB_H / 2 - 1) >> 16) & 0xFFu);
+ const int tr = (int)((px(&tc, FB_W - 1, 0) >> 16) & 0xFFu);
+ const int bl = (int)((px(&tc, 0, FB_H / 2 - 1) >> 16) & 0xFFu);
+ if (tl != 0 || br != 255)
+ return fail("to bottom right: the start and end corners must hold the end stops");
+ if (tr < 120 || tr > 135 || bl < 120 || bl > 135)
+ {
+ printf(" top-right %d, bottom-left %d\n", tr, bl);
+ return fail("to bottom right: the neighbouring corners must sit on the 50% line");
+ }
+ }
+
+ /* -----------------------------------------------------------------------
+ * Test 11: a repaint under a damage clip reproduces the full frame inside the clip and leaves the
+ * rest untouched — the dither is keyed to framebuffer coordinates, not to the window it was
+ * assembled in, so a small repaint never seams against the pixels around it. Covers the diagonal
+ * per-pixel path and the two axis-aligned paths (rows reused along x, one colour per row along y).
+ * ----------------------------------------------------------------------- */
+ {
+ static const float angles[] = {30.0f, 90.0f, 0.0f};
+ static uint32_t full[FB_W * FB_H];
+ for (size_t ai = 0; ai < sizeof(angles) / sizeof(angles[0]); ai++)
+ {
+ ERProps p;
+ memset(&p, 0, sizeof(p));
+ p.left = p.top = p.right = p.bottom = ER_LAYOUT_AUTO;
+ p.width = FB_W;
+ p.height = FB_H;
+ p.opacity = 255;
+ p.border_radius = 12;
+ p.gradient_type = ER_GRADIENT_LINEAR;
+ p.gradient_angle = angles[ai];
+ p.gradient_stop_count = 3;
+ p.gradient_stops[0].color = 0xFF0B0D12;
+ p.gradient_stops[0].position = 0.0f;
+ p.gradient_stops[1].color = 0xCC1A2B3C;
+ p.gradient_stops[1].position = 0.4f;
+ p.gradient_stops[2].color = 0xFF202830;
+ p.gradient_stops[2].position = 1.0f;
+ er_node_set_props(root, &p);
+
+ memset(tc.fb, 0, sizeof(tc.fb));
+ er_commit();
+ memcpy(full, tc.fb, sizeof(full));
+
+ const int cx = 3, cy = 5, cw = 21, ch = 13; /* crosses the rounded top-left corner */
+ memset(tc.fb, 0, sizeof(tc.fb));
+ er_push_clip_rect(cx, cy, cw, ch);
+ er_gradient_render(&root->props.view, 0, 0, FB_W, FB_H);
+ er_pop_clip_rect();
+ for (int yy = 0; yy < FB_H; yy++)
+ {
+ for (int xx = 0; xx < FB_W; xx++)
+ {
+ const bool inside = xx >= cx && xx < cx + cw && yy >= cy && yy < cy + ch;
+ const uint32_t want = inside ? full[yy * FB_W + xx] : 0u;
+ if (px(&tc, xx, yy) != want)
+ {
+ printf(" angle %.0f at (%d,%d): got %08X, want %08X\n", (double)angles[ai], xx, yy,
+ px(&tc, xx, yy), want);
+ return fail("damage clip: the clipped repaint differs from the full frame");
+ }
+ }
+ }
+ }
+ }
+
#else /* ERUI_GRADIENT == 0 */
/* -----------------------------------------------------------------------