The clip-layer composite shader sampled the offscreen layer at the wrong vertical position, so path-clipped content (e.g. a circular avatar) came out upside down on the GLES backend. `v_uv.y` runs bottom-to-top in screen space — the layer FBO has GL's lower-left origin, and `ortho_rect` plus the texture shader's flip establish that `v_uv.y == 1` is the top edge. The composite computed the fragment's screen Y as `bbox.y + v_uv.y * bbox.h`, which is the inverted Y, so it read the layer mirrored about the horizontal axis. Compute it as `bbox.y + (1 - v_uv.y) * bbox.h` instead. The mask sampling was already correct (and a symmetric circle hid the flip in the example; a photo reveals it). The software backend is unaffected — it clips through a coverage mask with no layer round-trip.
876 lines
32 KiB
Rust
876 lines
32 KiB
Rust
// 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;
|
||
}
|
||
"##;
|
||
|
||
// Clip-layer composite shader for `set_clip_path`. Path-clipped content is
|
||
// drawn to an offscreen layer; this composites that layer onto the canvas
|
||
// multiplied by an anti-aliased coverage mask, giving a smooth clipped edge.
|
||
// `u_layer` is the full-canvas layer texture (premultiplied), `u_mask` the
|
||
// path coverage rasterised over `u_bbox` (x, y, w, h in canvas pixels). The
|
||
// quad is drawn over the bbox; `v_uv` runs 0..1 across it. Both FBO-backed
|
||
// textures are sampled y-flipped (GL's lower-left origin).
|
||
pub( super ) const CLIP_COMPOSITE_FRAG_SRC: &str = r##"
|
||
precision mediump float;
|
||
varying vec2 v_uv;
|
||
uniform sampler2D u_layer;
|
||
uniform sampler2D u_mask;
|
||
uniform vec2 u_canvas;
|
||
uniform vec4 u_bbox;
|
||
void main()
|
||
{
|
||
// `v_uv.y` runs bottom-to-top in screen space (FBO origin is lower-left, as
|
||
// `ortho_rect` + the texture shader's flip establish), so the screen Y of
|
||
// this fragment is `bbox.y + (1 - v_uv.y) * bbox.h`.
|
||
vec2 sp = vec2( u_bbox.x + v_uv.x * u_bbox.z, u_bbox.y + ( 1.0 - v_uv.y ) * u_bbox.w );
|
||
vec2 luv = vec2( sp.x / u_canvas.x, 1.0 - sp.y / u_canvas.y );
|
||
vec4 col = texture2D( u_layer, luv );
|
||
float cov = texture2D( u_mask, vec2( v_uv.x, 1.0 - v_uv.y ) ).a;
|
||
gl_FragColor = col * cov;
|
||
}
|
||
"##;
|
||
|
||
// Fragment shader for single-channel glyph textures with color tint.
|
||
//
|
||
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`
|
||
// map the unit quad's (0,0)–(1,1) UV range into the glyph's sub-region:
|
||
// tex_uv = vec2(v_uv.x, 1 - v_uv.y) * u_uv_scale + u_uv_offset
|
||
// The Y-flip (`1 - v_uv.y`) corrects for fontdue bitmaps being stored
|
||
// top-row-first while GL tex coords have V=0 at the bottom.
|
||
//
|
||
// The atlas is GL_R8 on ES3 and GL_LUMINANCE on ES2 (see
|
||
// `gles_render/text.rs` module doc and `GlesCanvas::atlas_format`).
|
||
// Both formats put the coverage byte in `.r` when sampled, so this
|
||
// shader is identical for both.
|
||
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;
|
||
uniform vec2 u_uv_offset;
|
||
uniform vec2 u_uv_scale;
|
||
void main()
|
||
{
|
||
vec2 uv = vec2(v_uv.x, 1.0 - v_uv.y) * u_uv_scale + u_uv_offset;
|
||
float coverage = texture2D(u_sampler, uv).r;
|
||
float a = u_color.a * coverage * u_opacity;
|
||
gl_FragColor = vec4(u_color.rgb * a, a);
|
||
}
|
||
"##;
|
||
|
||
// Batched glyph shader. `a_pos` is pre-transformed into NDC by the
|
||
// CPU side (we know the surface size) so the shader has no MVP to
|
||
// apply — that lets us upload one VBO and draw every glyph of the
|
||
// text run in a single `glDrawArrays`. `a_uv` carries the per-vertex
|
||
// atlas coordinate ready to sample.
|
||
pub( super ) const GLYPH_BATCH_VERT_SRC: &str = r#"
|
||
attribute vec2 a_pos;
|
||
attribute vec2 a_uv;
|
||
varying vec2 v_uv;
|
||
void main()
|
||
{
|
||
v_uv = a_uv;
|
||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||
}
|
||
"#;
|
||
|
||
pub( super ) const GLYPH_BATCH_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, v_uv).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);
|
||
}
|
||
"##;
|
||
|