Files
levlib/draw/shaders/source/backdrop_blur.frag
T
zack 5317b8f142 Backdrop Path + Cybersteel (#23)
Co-authored-by: Zachary Levy <zachary@sunforge.is>
Reviewed-on: #23
2026-05-01 05:43:10 +00:00

156 lines
7.3 KiB
GLSL

#version 450 core
// Unified backdrop blur fragment shader.
// Handles both the 1D separable blur passes (mode 0, used for BOTH the H-pass and V-pass;
// `direction` picks the axis) and the composite pass (mode 1, reads the fully-blurred
// working texture, masks via RRect SDF, applies tint, and writes to source_texture with
// premultiplied-over blending). Working textures are sized at the full swapchain resolution;
// downsampled content occupies only a sub-rect at downsample factor > 1 (set via viewport).
//
// The composite blends with source_texture via the standard premultiplied-over blend state
// (ONE, ONE_MINUS_SRC_ALPHA).
//
// Backdrop primitives are tint-only — there is no outline. A specialized edge effect
// (e.g. liquid-glass-style refraction outlines) would be implemented as a dedicated
// primitive type with its own pipeline.
//
// Two modes, structurally distinct:
//
// Mode 0: 1D separable blur. Used for BOTH the H-pass and V-pass; `direction` (set in the
// per-pass uniforms) picks (1,0) for H or (0,1) for V. Reads the previous working-
// res texture and writes the next working-res texture. Fullscreen-triangle vertex
// output; gl_FragCoord.xy is in working-res target pixel space; UV =
// gl_FragCoord.xy * inv_working_size.
//
// Mode 1: composite. Reads the fully-blurred working-res texture, applies the SDF mask and
// tint, writes to source_texture. Instanced unit-quad vertex output covering the
// per-primitive bounds; gl_FragCoord.xy is in the full-resolution render target;
// UV into the blurred working texture =
// (gl_FragCoord.xy * inv_downsample_factor) * inv_working_size.
// No kernel is applied here — the blur is already complete.
//
// V-blur is run as its own working→working pass rather than folded into the composite. The
// folded variant produced a horizontal-vs-vertical asymmetry artifact: when V-blur sampled
// the H-blur output through the bilinear-upsample/SDF-mask/tint pipeline in one shader
// invocation, horizontal source features ended up looking sharper than vertical ones.
// Matching V's structure exactly to H's restores symmetry.
const uint MAX_KERNEL_PAIRS = 32;
// --- Inputs from vertex shader ---
layout(location = 0) in vec2 p_local;
layout(location = 1) in mediump vec4 f_color;
layout(location = 2) flat in vec2 f_half_size;
layout(location = 3) flat in vec4 f_radii;
layout(location = 4) flat in float f_half_feather;
// --- Output ---
layout(location = 0) out vec4 out_color;
// --- Sampler ---
// Mode 0: bound to downsample_texture. Mode 1: bound to h_blur_texture.
layout(set = 2, binding = 0) uniform sampler2D blur_input_tex;
// --- Uniforms (set 3) ---
// Per-bracket-substage. `mode` matches the vertex shader's mode (0 = H, 1 = V).
// `direction` selects the kernel axis for blur offsets.
// `kernel` holds the per-sigma weight/offset pairs computed CPU-side using the
// linear-sampling pair adjustment (RAD/Rákos).
layout(set = 3, binding = 0) uniform Uniforms {
vec2 inv_working_size; // 1.0 / working-resolution texture dimensions
uint pair_count; // number of (weight, offset) pairs; pair[0] is the center
uint mode; // 0 = H-blur, 1 = V-composite
vec2 direction; // (1,0) for H, (0,1) for V — multiplied into the kernel offset
float inv_downsample_factor; // 1.0 / downsample_factor (mode 1 only; mode 0 ignores)
float _pad0;
vec4 kernel[MAX_KERNEL_PAIRS]; // .x = weight (paired-sum for idx>0), .y = offset (texels)
};
// ---------------------------------------------------------------------------------------------------------------------
// ----- SDF helper --------------------
// ---------------------------------------------------------------------------------------------------------------------
float sdRoundedBox(vec2 p, vec2 b, vec4 r) {
vec2 rxy = (p.x > 0.0) ? r.xy : r.zw;
float rr = (p.y > 0.0) ? rxy.x : rxy.y;
vec2 q = abs(p) - b;
if (rr == 0.0) {
return max(q.x, q.y);
}
q += rr;
return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - rr;
}
float sdf_alpha(float d, float h) {
return 1.0 - smoothstep(-h, h, d);
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Blur sample loop --------------
// ---------------------------------------------------------------------------------------------------------------------
vec3 blur_sample(vec2 uv) {
vec3 color = kernel[0].x * texture(blur_input_tex, uv).rgb;
// Per-pair offset in texel space, projected onto the active axis.
vec2 axis_step = direction * inv_working_size;
for (uint i = 1u; i < pair_count; i += 1u) {
float w = kernel[i].x;
float off = kernel[i].y;
vec2 step_uv = off * axis_step;
color += w * texture(blur_input_tex, uv - step_uv).rgb;
color += w * texture(blur_input_tex, uv + step_uv).rgb;
}
return color;
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Main --------------------------
// ---------------------------------------------------------------------------------------------------------------------
void main() {
if (mode == 0u) {
// ---- Mode 0: 1D separable blur (used for both H-pass and V-pass).
// gl_FragCoord is in working-res target pixel space; sample the previous working-res
// texture along `direction` with the kernel.
vec2 uv = gl_FragCoord.xy * inv_working_size;
vec3 color = blur_sample(uv);
out_color = vec4(color, 1.0);
return;
}
// ---- Mode 1: composite per-primitive.
// RRect SDF — early discard for fragments well outside the masked region.
float d = sdRoundedBox(p_local, f_half_size, f_radii);
if (d > f_half_feather) {
discard;
}
// fwidth-based normalization for AA (matches main pipeline approach).
float grad_magnitude = max(fwidth(d), 1e-6);
float d_n = d / grad_magnitude;
float h_n = f_half_feather / grad_magnitude;
// Sample the fully-blurred working-res texture. gl_FragCoord is full-res; convert to
// working-res UV via inv_downsample_factor. No kernel is applied — the H+V blur passes
// already produced the final blurred image; this is just an upsample + tint.
vec2 uv = (gl_FragCoord.xy * inv_downsample_factor) * inv_working_size;
vec3 color = texture(blur_input_tex, uv).rgb;
// Tint composition: inside the masked region the panel is fully opaque — it completely
// hides the original framebuffer content, just like real frosted glass and like iOS
// UIBlurEffect / CSS backdrop-filter. f_color.rgb specifies the tint color; f_color.a
// specifies the tint *mix strength* (NOT panel opacity). At alpha=0 we see the pure
// blur; at alpha=255 we see the blur fully multiplied by the tint color.
//
// Output is premultiplied to match the ONE, ONE_MINUS_SRC_ALPHA blend state. Coverage
// (the SDF mask's edge AA) modulates only the alpha channel, never the panel-vs-source
// blend; that way edge pixels still feather correctly while mid-panel pixels stay fully
// opaque.
mediump vec3 tinted = mix(color, color * f_color.rgb, f_color.a);
mediump float coverage = sdf_alpha(d_n, h_n);
out_color = vec4(tinted * coverage, coverage);
}