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 . 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 ` 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 ` 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 ` 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 + * `` is this plus 180°). A gradient_corner instead aims the line as CSS `to ` 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 #include #include @@ -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 `: 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 */ /* -----------------------------------------------------------------------