First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

812
src/gles_render/shaders.rs Normal file
View File

@@ -0,0 +1,812 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! GLES 2/3 shader sources used by `GlesCanvas`. All fragment shaders
//! emit premultiplied-alpha colour so they compose correctly under
//! the pipeline's default `glBlendFunc(ONE, ONE_MINUS_SRC_ALPHA)`.
//!
//! Every constant is `pub(super)` so only files inside
//! `crate::gles_render` can reach them — callers always go through
//! `GlesCanvas`'s public methods, not the raw shader source.
// Vertex shader shared by both programs (just transforms a unit quad).
// GLSL ES 1.00 — works on both GLES2 and GLES3 contexts (forward compatible
// when no `#version` directive is present).
pub( super ) const VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
uniform mat4 u_mvp;
void main()
{
v_uv = a_pos;
gl_Position = u_mvp * vec4(a_pos, 0.0, 1.0);
}
"#;
// Fragment shader for solid/rounded rects (signed-distance smoothstep at edge).
//
// `u_stroke == 0` ⇒ filled rect. The shader fills the rounded shape with
// `u_color`, antialiased over a 1-pixel band at the edge.
// `u_stroke > 0` ⇒ stroked outline of width `u_stroke`, centered on the
// rounded boundary, so outlines keep their corner radius.
//
// Distance formula is Iñigo Quílez's exact SDF for a rounded box, extended
// to per-corner radii by picking `r` per-quadrant:
// r = u_radii[ corner_index_from_sign( p - center ) ]
// q = abs(p - center) - (size/2 - r)
// d = min(max(q.x, q.y), 0) + length(max(q, 0)) - r
// `u_radii` is ordered `(tl, tr, br, bl)` clockwise from top-left and is
// uploaded as `Corners::to_uniform()`. A uniform-radii fill is the
// degenerate case where every component is equal — the per-quadrant
// branch collapses to the single-`r` formula.
//
// `u_pad` lets the caller draw the quad larger than `u_size` (used by
// `stroke_rect`, which expands the quad by `stroke/2` on each side so the
// outer half of the stroke has fragments to cover). The shader maps `v_uv`
// from the larger quad back into the original-rect space:
// p = v_uv * (size + 2*pad) - pad
// so `p` ranges `[-pad, size+pad]`. Keeping `u_size` and `u_radii` at the
// *original* values is essential — expanding them shifts the SDF zero-line
// outward and breaks the circle case (radius = size/2).
//
// Each component of `u_radii` is clamped to `min(size.x, size.y) * 0.5`
// before use. Callers frequently pass very large values (e.g.
// `theme::RADIUS = 100`) as a "please make this a pill / capsule"
// sentinel. Without the clamp, `size/2 - r` goes negative in the shorter
// dimension and the rounded-box formula degenerates — rendering an
// ellipse in the middle of the rect instead of a pill.
//
// Emits premultiplied-alpha colour. The pipeline blend is
// `(ONE, ONE_MINUS_SRC_ALPHA)`, so every shader that writes colour must
// premultiply its RGB by the final alpha. This matters for non-opaque
// fills (coverage from the SDF, translucent `u_color.a`) where straight-
// alpha output would under-saturate antialiased edges.
pub( super ) const RECT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec4 u_color;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_stroke;
uniform float u_pad;
// Per-fragment corner radius lookup. `c` is the fragment position
// relative to the rect's centre. Inside the shader, p.y grows UPWARD:
// `ortho_rect` flips the v_uv axis so v_uv.y=0 maps to the bottom edge
// of the rect in screen space and v_uv.y=1 to the top. So with `c =
// p - size/2`:
// c.x < 0, c.y > 0 → top-left → r.x
// c.x > 0, c.y > 0 → top-right → r.y
// c.x > 0, c.y < 0 → bottom-right → r.z
// c.x < 0, c.y < 0 → bottom-left → r.w
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
float r_max = max(max(u_radii.x, u_radii.y), max(u_radii.z, u_radii.w));
if (r_max <= 0.0 && u_stroke <= 0.0 && u_pad <= 0.0)
{
float a = u_color.a;
gl_FragColor = vec4(u_color.rgb * a, a);
return;
}
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px-wide AA band (half-width 1.0). A narrower (±0.5) band is
// only sampled when pixel centres happen to fall inside it — when
// the rasterizer grid aligns with the edge at subpixel ±0.5 the
// band is jumped over and the transition becomes a binary step
// (visible stair-stepping on curved edges). Doubling the band
// guarantees at least one partial-coverage sample per scanline
// regardless of alignment. The quad pad set by the caller is 1 px
// so this still fits inside the geometry at d ≤ 1.
float coverage;
if (u_stroke > 0.0)
{
float half_w = u_stroke * 0.5;
coverage = 1.0 - smoothstep(half_w - 1.0, half_w + 1.0, abs(d));
} else {
coverage = 1.0 - smoothstep(-1.0, 1.0, d);
}
float a = u_color.a * coverage;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Fragment shader for RGBA textured quads (images / icons).
//
// Flips uv.y because images are uploaded top-down (CPU convention: row 0 =
// top of source) but `glTexImage2D` puts the first row at the texture's
// lower-left. With the quad orientation produced by `ortho_rect`, sampling
// `v_uv` directly would draw the source upside-down on screen.
//
// Texture data arrives PREMULTIPLIED — `upload_rgba_texture` premuls the
// straight-alpha CPU buffer once at upload. GL_LINEAR sampling can then
// blend across texel boundaries without halo artefacts (a fully opaque
// black texel next to a fully transparent white texel interpolates to
// `( 0, 0, 0, 0.5 )` instead of the halo-producing `( 0.5, 0.5, 0.5, 0.5 )`
// of straight-alpha interpolation). Multiplying premul by uniform opacity
// preserves the invariant `rgb == rgb_straight * a`.
pub( super ) const TEX_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
void main()
{
gl_FragColor = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)) * u_opacity;
}
"##;
// Fragment shader for single-channel glyph textures with color tint. Same
// Y-flip rationale as `TEX_FRAG_SRC`. The texture is `GL_LUMINANCE`, which
// replicates the uploaded byte into `.r`, `.g`, `.b` (with `.a = 1`), so the
// coverage value is read from `.r`. We deliberately avoid `GL_ALPHA` here:
// some Mesa GLES3 paths handle the legacy alpha-only format inconsistently
// (sampled `.a` returns near-zero in glyph interiors, leaving only the
// antialias edges visible — text appears as thin faded outlines instead of
// solid strokes). `LUMINANCE` is also a legacy format but its mapping to
// `.r=.g=.b=data` is well-supported across ES2/ES3 drivers.
pub( super ) const GLYPH_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform vec4 u_color;
uniform float u_opacity;
void main()
{
float coverage = texture2D(u_sampler, vec2(v_uv.x, 1.0 - v_uv.y)).r;
float a = u_color.a * coverage * u_opacity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Vertex shader for the present-blit: maps the unit quad to fullscreen NDC
// without going through an MVP. UVs are equal to a_pos so the FBO appears
// the same way up on the default framebuffer as it was rendered into the FBO
// (both use GL pixel coords with origin at bottom-left).
pub( super ) const BLIT_VERT_SRC: &str = r#"
attribute vec2 a_pos;
varying vec2 v_uv;
void main()
{
v_uv = a_pos;
gl_Position = vec4(a_pos * 2.0 - 1.0, 0.0, 1.0);
}
"#;
// Fragment shader for the present-blit: straight texture sample, no opacity.
pub( super ) const BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
void main()
{
gl_FragColor = texture2D(u_sampler, v_uv);
}
"#;
// Fragment shader for inter-FBO blits (used by `blit` to composite a sub-canvas
// back onto its parent). Reuses `VERT_SRC` so the quad is positioned via
// `u_mvp` like any other textured draw. No Y-flip: source and destination are
// both FBOs storing content in GL's native bottom-up convention, so sampling
// at `v_uv` puts the visually-top row of the source on the top of the dest.
//
// `u_fade_bottom_px` feathers the bottom edge of the blit: the last
// `u_fade_bottom_px` visible rows ramp the source alpha linearly from 1.0
// (at the inner edge of the band) to 0.0 (at the very bottom row), so a
// growing viewport does not look like a knife cut against whatever is
// behind it. `v_uv.y == 1.0` is the visually-top row of the source (FBO
// origin is bottom-left), so `v_uv.y * u_height_px` is the distance in
// source pixels from the bottom; the linear ramp falls out of dividing
// that by `u_fade_bottom_px` and clamping. With `u_fade_bottom_px == 0`
// the divide is skipped and the blit stays hard-edged. Linear (not
// smoothstep) because premultiplied output multiplied by a linear alpha
// already reads as a soft fade — smoothstep would compress the ramp into
// fewer effective rows and reintroduce a faint shoulder.
pub( super ) const SUB_BLIT_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_sampler;
uniform float u_opacity;
uniform float u_fade_bottom_px;
uniform float u_height_px;
void main()
{
vec4 c = texture2D(u_sampler, v_uv);
float fade = 1.0;
if (u_fade_bottom_px > 0.0)
{
float y_from_bottom = v_uv.y * u_height_px;
fade = clamp(y_from_bottom / u_fade_bottom_px, 0.0, 1.0);
}
gl_FragColor = c * (u_opacity * fade);
}
"#;
// Linear gradient shader. Samples a CPU-baked 1D LUT (`u_lut`, size
// `gradient_lut::LUT_SAMPLES × 1`, RGBA8, straight-alpha) along the
// CSS linear-gradient convention: the gradient line passes through the
// centre of the rect, `0°` points up, positive angles rotate clockwise.
// Stop positions outside `[0, 1]` are already baked into the LUT via
// linear extrapolation, so the shader only remaps `t` from the extended
// domain `[u_lut_domain_min, u_lut_domain_min + u_lut_domain_span]`
// into the texture's `[0, 1]` sampling range.
//
// The same SDF as `RECT_FRAG_SRC` is reused for the rounded-rect / pill
// silhouette; the coverage multiplies the fragment's alpha before
// premultiplying.
pub( super ) const LINEAR_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_dir;
uniform vec2 u_size;
uniform float u_line_length;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Gradient evaluated in rect-local pixel space. `p` already accounts
// for `u_pad` (set by the caller to expand the quad for AA), so the
// gradient direction stays anchored to the original rect even when
// the quad spills outside.
float dist = dot(c, u_dir);
float t = 0.5 + dist / u_line_length;
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Radial gradient shader. `u_center` is in box-relative fractions
// (`[0, 1]` on each axis), `u_radius_frac` is the radial extent in the
// same fractional space. `t = distance(v_uv, u_center) / u_radius_frac`
// so stops at `position == 1.0` fall exactly at the chosen radius.
pub( super ) const RADIAL_GRADIENT_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_lut;
uniform vec2 u_center;
uniform float u_radius_frac;
uniform vec2 u_size;
uniform vec4 u_radii;
uniform float u_pad;
uniform float u_lut_domain_min;
uniform float u_lut_domain_span;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * vec2(u_pad)) - vec2(u_pad);
vec2 c = p - u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(u_size.x, u_size.y) * 0.5);
vec2 q = abs(c) - (u_size * 0.5 - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
// 2-px AA band — see RECT_FRAG_SRC for the grid-alignment rationale.
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
// Centre/extent in box-relative fractions evaluated against `p`
// (which already accounts for `u_pad`), so the radial centre stays
// anchored to the original rect when the quad spills outside.
vec2 t_uv = p / u_size;
float t = distance(t_uv, u_center) / max(u_radius_frac, 1e-6);
float t_lut = (t - u_lut_domain_min) / u_lut_domain_span;
vec4 grad = texture2D(u_lut, vec2(clamp(t_lut, 0.0, 1.0), 0.5));
float a = grad.a * coverage;
gl_FragColor = vec4(grad.rgb * a, a);
}
"##;
// Outer drop shadow shader.
//
// Rather than baking a blurred shape into an intermediate texture via a
// separable Gaussian pass, this uses the analytical soft-shadow
// approximation `exp(-d² / (2σ²))` over the rounded-rect SDF. The maths:
//
// • `d` is the signed distance from the fragment to the (possibly
// spread-expanded) shape. `d < 0` is inside, `d > 0` is outside.
// • `intensity = 1.0` inside the shape, `exp(-d²/2σ²)` outside.
// • `σ = shadow.blur / 2` (CSS blur radius → Gaussian sigma).
//
// The result is visually indistinguishable from a true Gaussian blur for
// small to moderate σ. For very large σ the tail of `exp()` departs from
// a real Gaussian, at which point a real two-pass blur or a correction
// factor would be needed.
//
// Running the shadow as a single analytic pass means there's no need for
// a per-shadow FBO, no ping-pong, and no framebuffer readback: it is all
// one cheap draw call.
// Inner (inset) shadow shader.
//
// Two SDFs cooperate here:
//
// • `d_outer` — the signed distance to the surface itself. Positive
// outside, negative inside. We multiply the final intensity by the
// smooth-step coverage of this SDF so the inset never leaks past the
// shape's own silhouette.
//
// • `d_inner` — the signed distance to the shape shifted by
// `shadow.offset` and eroded by `shadow.spread`. This is the "hole"
// the inset falls into: `d_inner ≥ 0` means the pixel is on the
// shadow side of the offset edge (full intensity); `d_inner < 0`
// means the pixel is deeper into the unshadowed interior, where the
// intensity decays as `exp(-d_inner² / (2σ²))`.
//
// Together they reproduce the CSS `inset` semantics: the shadow sits
// inside the rounded rect, biased toward the side opposite `offset`,
// and fades toward the middle with a Gaussian falloff. Premul output
// matches the rest of the pipeline.
pub( super ) const SHADOW_INSET_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer SDF: the shape itself, untransformed. Used to clip the inset
// to the silhouette. 2-px AA band — see RECT_FRAG_SRC.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Inner SDF: the shape shifted by offset and eroded by spread. Its
// distance drives the Gaussian falloff. The inner radii match the
// outer per-corner shape, eroded by `u_spread` (clamped at zero).
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
// Degenerate case: spread larger than half the shape erodes it to a
// point. Guard so `half_inner` never goes negative.
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
pub( super ) const SHADOW_OUTER_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec4 u_color;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5 + vec2(u_spread);
// Per-corner shadow radius — outer shape grown uniformly by spread.
vec4 r_base = max(u_radii + vec4(u_spread), vec4(0.0));
float r = corner_radius(c, r_base);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float intensity;
if (d <= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d * d) / (2.0 * s * s));
}
float a = u_color.a * intensity;
gl_FragColor = vec4(u_color.rgb * a, a);
}
"##;
// Horizontal Gaussian blur for the backdrop pipeline. Part one of the
// separable-Gaussian pair: samples the aux_a snapshot horizontally and
// writes to aux_b at the fragment's pixel position. Drawn over a
// vertically-extended surface rect so the vertical pass — which reads
// aux_b up to ±3σ away from each target pixel — has valid data wherever
// it samples. `u_canvas_size` maps `gl_FragCoord` into the aux texture,
// and the aux pair is sized to the main FBO so that mapping is trivial.
//
// Kernel: 41 taps (`RADIUS = 20`), weights computed inline as
// `exp(-i²/(2σ²))` and normalized by the sum. At σ ≈ 11 this captures
// ~93 % of the Gaussian mass — the remainder is a faint tail that is
// visually imperceptible. For σ past ~15 the kernel radius would need
// to grow or a downsample pass would be required; everything else in
// the pipeline already deals in σ and would not change.
pub( super ) const BACKDROP_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 20;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
// Vertical Gaussian blur + SDF clip + tint, the last pass of the
// backdrop pipeline. Runs on a quad matching the surface rect and
// writes to the main FBO.
//
// Per fragment: (1) computes the rounded-rect SDF coverage just like
// the rect shader so the backdrop is clipped to the surface shape with
// a 1-pixel anti-aliased edge; (2) samples `u_source` (the H-blurred
// aux_b) vertically with the same 41-tap Gaussian as the H pass; (3)
// applies the optional `u_tint` over the blurred sample using standard
// premul-over math; (4) outputs premultiplied with `alpha = coverage`.
//
// Alpha handling. The output is `(result_rgb * coverage, coverage)` —
// not `(result_rgb * coverage * result_a, coverage * result_a)`. The
// snapshot holds premultiplied content that is effectively opaque
// inside the canvas (every pixel has been written by some draw, even
// if that draw was `clear(0,0,0,0)`; the FBO is never sampled outside
// the canvas bounds). Treating the blurred sample's alpha as 1.0 at
// output time means the composite pass REPLACES the base pixel inside
// the surface shape with the tinted blurred content, rather than
// alpha-blending on top of it. That is the correct semantics for
// `backdrop-filter`: you want the original content to disappear
// entirely where the surface covers it, replaced by the blurred
// version.
//
// `fill_backdrop` runs this before outer shadows / fill / insets so
// later passes composite on top of the blurred backdrop.
pub( super ) const BACKDROP_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 20;
// Rounded-rect SDF clip. 2-px AA band — see RECT_FRAG_SRC.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
// Vertical Gaussian on the H-blurred source.
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
// Optional tint, "tint over blurred" in premul space.
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// ── Fast (low-quality) variants of the backdrop shaders ────────────────
//
// Same separable-Gaussian pipeline as the full-quality pair above, but
// with `RADIUS = 4` instead of `RADIUS = 20`. That cuts each pass from
// 41 taps to 9 — a ~4.5× reduction in fragment-shader work — and
// shrinks the snapshot region the renderer has to copy from the main
// FBO from `target ± 21` to `target ± 5` pixels. Used during motion
// via [`super::low_quality_paint`]; the static frame is painted with
// the full-quality pair.
//
// `u_sigma` is still a uniform so the CPU side can clamp it to a
// value compatible with the smaller kernel (typically ≤ 2.0), keeping
// the kernel a sensible Gaussian rather than a sharply truncated one.
// Visually this means a thinner blur band during motion, which fades
// back to the full blur on the static frame.
pub( super ) const BACKDROP_FAST_BLUR_H_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform vec2 u_canvas_size;
uniform float u_sigma;
void main()
{
const int RADIUS = 4;
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
gl_FragColor = total / total_w;
}
"#;
pub( super ) const BACKDROP_FAST_COMPOSITE_FRAG_SRC: &str = r#"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_source;
uniform vec2 u_canvas_size;
uniform vec2 u_texel;
uniform float u_sigma;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform vec4 u_tint;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
const int RADIUS = 4;
// Rounded-rect SDF clip — identical to the full-quality variant.
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
vec2 half_sz = u_size * 0.5;
float r = corner_radius(c, u_radii);
r = min(r, min(half_sz.x, half_sz.y));
vec2 q = abs(c) - (half_sz - vec2(r));
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
float coverage = 1.0 - smoothstep(-1.0, 1.0, d);
if (coverage <= 0.0) { discard; }
vec2 uv = gl_FragCoord.xy / u_canvas_size;
vec4 total = vec4(0.0);
float total_w = 0.0;
for (int i = -RADIUS; i <= RADIUS; i++)
{
float fi = float(i);
float w = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
total += texture2D(u_source, uv + fi * u_texel) * w;
total_w += w;
}
vec4 blurred = total / total_w;
vec3 tint_premul = u_tint.rgb * u_tint.a;
vec3 result_rgb = tint_premul + blurred.rgb * (1.0 - u_tint.a);
gl_FragColor = vec4(result_rgb * coverage, coverage);
}
"#;
// Inset shadow with CSS `Overlay` blend. Uses the same SDF dance as
// `SHADOW_INSET_FRAG_SRC` to compute `intensity` (outer-silhouette clip
// + Gaussian falloff from the offset-shifted inner SDF), then samples
// the snapshotted FBO content under the fragment and applies the
// per-channel Overlay formula:
//
// overlay(base, src) = base < 0.5 ? 2 * base * src
// : 1 - 2 * (1 - base) * (1 - src)
//
// Output is premultiplied with `mask = u_color.a * intensity` — so the
// standard `(ONE, ONE_MINUS_SRC_ALPHA)` blend on top of the main FBO
// yields `result = overlay_rgb * mask + base * (1 - mask)`, i.e. the
// Overlay effect modulated by the shadow mask, composed onto the
// original backdrop. The snapshot texture holds premultiplied content
// matching the main FBO, so we unpremultiply before applying Overlay.
//
// `u_canvas_size` is the main FBO's pixel size. `gl_FragCoord` has its
// origin at bottom-left in GLES, which matches the FBO's native
// orientation, so `gl_FragCoord.xy / u_canvas_size` gives the texture
// coordinates of the pixel we're about to write to. No Y-flip needed.
pub( super ) const SHADOW_INSET_OVERLAY_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_size;
uniform vec2 u_padding;
uniform vec4 u_radii;
uniform float u_spread;
uniform float u_sigma;
uniform vec2 u_offset;
uniform vec4 u_color;
uniform sampler2D u_snapshot;
uniform vec2 u_canvas_size;
// Per-fragment corner radius lookup — see RECT_FRAG_SRC for the
// quadrant convention.
float corner_radius(vec2 c, vec4 r)
{
float top = (c.x < 0.0) ? r.x : r.y;
float bottom = (c.x < 0.0) ? r.w : r.z;
return (c.y > 0.0) ? top : bottom;
}
void main()
{
vec2 p = v_uv * (u_size + 2.0 * u_padding) - u_padding;
vec2 c = p - u_size * 0.5;
// Outer silhouette clip — same as SHADOW_INSET_FRAG_SRC, 2-px AA band.
vec2 half_outer = u_size * 0.5;
float r_outer = corner_radius(c, u_radii);
r_outer = min(r_outer, min(half_outer.x, half_outer.y));
vec2 q_outer = abs(c) - (half_outer - vec2(r_outer));
float d_outer = min(max(q_outer.x, q_outer.y), 0.0) + length(max(q_outer, 0.0)) - r_outer;
float outer_coverage = 1.0 - smoothstep(-1.0, 1.0, d_outer);
// Offset-shifted inner SDF → Gaussian intensity. Per-corner inner
// radii match the outer shape eroded by `u_spread`.
vec2 p_shifted = p - u_offset;
vec2 c_shifted = p_shifted - u_size * 0.5;
vec2 half_inner = half_outer - vec2(u_spread);
half_inner = max(half_inner, vec2(1e-3));
vec4 inner_radii = max(u_radii - vec4(u_spread), vec4(0.0));
float r_inner = corner_radius(c_shifted, inner_radii);
r_inner = min(r_inner, min(half_inner.x, half_inner.y));
vec2 q_inner = abs(c_shifted) - (half_inner - vec2(r_inner));
float d_inner = min(max(q_inner.x, q_inner.y), 0.0) + length(max(q_inner, 0.0)) - r_inner;
float intensity;
if (d_inner >= 0.0)
{
intensity = 1.0;
}
else
{
float s = max(u_sigma, 0.5);
intensity = exp(-(d_inner * d_inner) / (2.0 * s * s));
}
intensity *= outer_coverage;
float mask = u_color.a * intensity;
// Sample the snapshot at the fragment's FBO position. The snapshot
// holds premultiplied content; divide by alpha before applying the
// straight-alpha Overlay formula. Guard alpha=0 to avoid NaNs.
vec2 snap_uv = gl_FragCoord.xy / u_canvas_size;
vec4 snap = texture2D(u_snapshot, snap_uv);
vec3 base = snap.a > 0.0 ? snap.rgb / snap.a : vec3(0.0);
vec3 src = u_color.rgb;
// Per-channel Overlay. `step(0.5, base)` yields 0 where base < 0.5
// and 1 otherwise; `mix` picks multiply vs screen accordingly.
vec3 multiply = 2.0 * base * src;
vec3 screen = 1.0 - 2.0 * (1.0 - base) * (1.0 - src);
vec3 overlay = mix(multiply, screen, step(0.5, base));
// Output premul: `(overlay * mask, mask)`. Combined with the premul
// over blend this replaces the base by `overlay` wherever mask == 1
// and leaves it untouched where mask == 0.
gl_FragColor = vec4(overlay * mask, mask);
}
"##;