Files
FlClash/shaders/liquid_glass.frag
chen08209 7c9627fffb feat(ui): add Liquid Glass and slide-to-pick popup menus
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
2026-10-17 17:21:43 +08:00

478 lines
19 KiB
GLSL

#version 460 core
#include <flutter/runtime_effect.glsl>
precision highp float;
// The texture's size; the order of the rest is the order in which
// _LiquidGlassLayer in lib/ui/liquid_glass.dart writes them.
uniform vec2 uSize;
// Glass coordinates, in dp from its top left, of FlutterFragCoord: an origin,
// then a scale per unit.
uniform vec4 uFrame;
// Glass coordinates of the texture's pixels, as uFrame.
uniform vec4 uSample;
// The first and last pixel centers the pane may read, as a capture holds the
// backdrops of other panes beside its own.
uniform vec4 uClamp;
// Dp per device pixel, and the frost sigma in its own pixels the texture
// already holds, as a capture is blurred before any pane reads it.
uniform vec2 uTexels;
// As uSample and uClamp, for a capture blurred by the style's haze, and the
// share of the backdrop seen through it.
uniform vec4 uHazeSample;
uniform vec4 uHazeClamp;
uniform float uHaze;
// Width, height, bevel width, frost sigma.
uniform vec4 uShape;
// Corner radii: top left, top right, bottom right, bottom left.
uniform vec4 uCorners;
// The superellipse corners the pane is clipped to, three to a corner in the
// order of uCorners: n, J and the arc's radius for the octant along the top or
// bottom, the same for the one along the side, then both arcs' centers. A
// sharp corner has n 0.
uniform vec4 uCurves[12];
// Refraction depth at the rim, saturation, brightness. A negative depth makes
// a raised lens, which bends outward on UIKit's profile.
uniform vec3 uOptics;
// Straight alpha.
uniform vec4 uTint;
// Highlight along the top and bottom of the rim, the dp it fades over, how
// much a lens's rounded ends darken.
uniform vec3 uLight;
// Glow center, glow strength, how far the tint thickens over content much
// darker than it, or when negative much lighter.
uniform vec4 uGlow;
// How far the glow reaches, and the share of it spread over the whole pane.
uniform vec2 uGlowSpread;
// How much the dark line around the rim darkens the backdrop, how far apart in
// dp the color channels part along the rim, and the frost sigma the rim adds
// at its edge, easing off across the bevel.
uniform vec3 uEdge;
// The highlight's share in a faint tail kRimTail long, how far its fall
// round rounded ends goes from the tab bar's to iOS 27 controls', and its
// share added as light rather than screened; then the line at the sides.
uniform vec4 uRim;
// Above 0.5 the pane takes the outline of uProfile in place of its corners.
uniform float uOutlined;
// The outline's distance from the center along 64 even turns, the first
// toward +x and the rest toward +y, four to a vector.
uniform vec4 uProfile[16];
// A menu's highlight (LiquidGlassRim): light added at eight turns from the
// vertical through the center, rising from 0 to a right angle, and across
// bands inward from the rim, where each ends in dp and its share.
uniform vec4 uShineTurns[2];
uniform vec4 uShineLit[2];
uniform vec4 uShineEnds[3];
uniform vec4 uShineShares[3];
uniform sampler2D uTexture;
out vec4 fragColor;
const vec3 kLuma = vec3(0.2126, 0.7152, 0.0722);
const float kTau = 6.28318530718;
const float kTurns = 64.0;
// Fewer taps leave a swirl over sharp text; 64 leave a faint one at 3x, too
// fine to see. A count taken from a uniform costs as much as 96 constant taps,
// so a narrower frost takes a constant count of its own, its taps no further
// apart than 64 leave those of the widest.
const int kFrostTaps = 64;
const float kGoldenAngle = 2.39996322973;
// 1.2 times the engine's kernel reach of (sigma - 0.5) * sqrt(3) pixels, which
// on a Vogel disc fits its blur best.
const float kFrostReach = 2.07846096908;
// Measured on iOS 27: the width of the dark line in dp and the most it takes
// off a channel of the backdrop.
const float kLine = 0.67;
const float kLineCap = 0.298;
// The most the engine's superellipse corner lies inside a circular one, as a
// share of the radius, with room to spare over the 1.8% it reaches.
const float kCornerGap = 0.03;
// How far in dp a lens's ends darken in from the rim.
const float kEndShade = 3.0;
// How far in dp from the rim the color channels part, and where they start
// to close: iOS parts them nearly as far across a lens's band as at its rim.
const float kPartReach = 10.0;
const float kPartHold = 7.0;
// Measured on iOS 27 controls: how far in dp the highlight's tail reaches.
const float kRimTail = 4.5;
// A frost this wide in dp reads the backdrop's luma once, as a haze does.
const float kSteadyFrost = 8.0;
vec4 texel(vec2 pixel) {
return texture(
uTexture,
clamp(pixel, uClamp.xy, min(uClamp.zw, uSize - 0.5)) / uSize
);
}
vec4 sampleGlass(vec2 point) {
return texel((point - uSample.xy) / uSample.zw);
}
vec4 sampleHaze(vec2 point) {
vec2 pixel = (point - uHazeSample.xy) / uHazeSample.zw;
return texture(
uTexture,
clamp(pixel, uHazeClamp.xy, min(uHazeClamp.zw, uSize - 0.5)) / uSize
);
}
#define FROST_DISC(taps) \
float decay = exp(-falloff / float(taps)); \
float weight = sqrt(decay); \
vec2 turn = vec2(cos(kGoldenAngle), sin(kGoldenAngle)); \
vec2 ray = vec2(1.0, 0.0); \
vec4 sum = vec4(0.0); \
float total = 0.0; \
for (int i = 0; i < taps; i++) { \
float spread = sqrt((float(i) + 0.5) / float(taps)); \
sum += texel(center + ray * reach * spread) * weight; \
total += weight; \
weight *= decay; \
ray = vec2(ray.x * turn.x - ray.y * turn.y, ray.x * turn.y + ray.y * turn.x); \
} \
return sum / total;
vec4 frostDisc16(vec2 center, vec2 reach, float falloff) {
FROST_DISC(16)
}
vec4 frostDisc32(vec2 center, vec2 reach, float falloff) {
FROST_DISC(32)
}
vec4 frostDisc64(vec2 center, vec2 reach, float falloff) {
FROST_DISC(kFrostTaps)
}
// The engine's Gaussian of a frost sigma in device pixels, past what the
// texture holds, read on a Vogel disc.
vec4 sampleFrosted(vec2 point, float frost) {
vec2 sigma = frost / uSample.zw;
sigma = sqrt(max(sigma * sigma - uTexels.y * uTexels.y, vec2(0.0)));
vec2 reach = max(sigma - 0.5, 0.0) * kFrostReach;
if (reach.x <= 0.0 || reach.y <= 0.0) {
return sampleGlass(point);
}
vec2 ratio = reach / sigma;
float falloff = 0.5 * ratio.x * ratio.y;
vec2 center = (point - uSample.xy) / uSample.zw;
float widest = max(sigma.x, sigma.y);
if (widest <= 2.0) {
return frostDisc16(center, reach, falloff);
}
if (widest <= 3.5) {
return frostDisc32(center, reach, falloff);
}
return frostDisc64(center, reach, falloff);
}
// Its taps sit a frost sigma apart, so that a glyph passing under one point
// does not flicker the tint; a haze or a wide frost is blurred enough to read
// once.
vec4 sampleBehind(vec2 point) {
if (uHaze > 0.0) {
return sampleHaze(point);
}
if (uShape.w >= kSteadyFrost) {
return sampleGlass(point);
}
vec2 spread = vec2(uShape.w, -uShape.w);
return 0.25 * (sampleGlass(point + spread.xx)
+ sampleGlass(point + spread.yy)
+ sampleGlass(point + spread.xy)
+ sampleGlass(point + spread.yx));
}
float roundedBoxDistance(vec2 p, vec2 halfSize, float radius) {
vec2 q = abs(p) - halfSize + radius;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}
vec2 roundedBoxNormal(vec2 p, vec2 halfSize, float radius) {
vec2 q = abs(p) - halfSize + radius;
vec2 n = q.x > 0.0 && q.y > 0.0
? normalize(q)
: (q.x > q.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0));
return n * vec2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0);
}
// How far inside one octant of the engine's rounded superellipse a point lies,
// in the octant's frame (impeller/geometry/round_superellipse_param.cc): a
// superellipse of half side a up to J, then a circular arc. Close to exact
// only within a few dp of the curve.
float octantInset(vec2 point, float a, vec4 curve, vec2 center) {
vec2 fromCenter = point - center;
vec2 toJoint = curve.yz - center;
if (toJoint.x * fromCenter.y < toJoint.y * fromCenter.x) {
return curve.w - length(fromCenter);
}
float n = curve.x;
vec2 s = max(point, 0.0) / a;
float major = max(max(s.x, s.y), 1e-4);
float norm = major * pow(1.0 + pow(min(s.x, s.y) / major, n), 1.0 / n);
vec2 slope = pow(s / norm, vec2(n - 1.0));
return a * (1.0 - norm) / max(length(slope), 1e-4);
}
// A corner's quadrant lies about the pane's center, as the engine lays it when
// all corners are alike, and its octants part along the diagonal through where
// their arcs meet, half the difference of the sides off center.
float cornerInset(vec2 p, vec2 halfSize) {
vec2 side = step(0.0, p);
vec4 along = mix(
mix(uCurves[0], uCurves[3], side.x),
mix(uCurves[9], uCurves[6], side.x),
side.y
);
vec4 across = mix(
mix(uCurves[1], uCurves[4], side.x),
mix(uCurves[10], uCurves[7], side.x),
side.y
);
vec4 centers = mix(
mix(uCurves[2], uCurves[5], side.x),
mix(uCurves[11], uCurves[8], side.x),
side.y
);
vec2 q = abs(p);
if (along.x < 2.0) {
return min(halfSize.x - q.x, halfSize.y - q.y);
}
float c = halfSize.x - halfSize.y;
if (q.y + c >= q.x) {
return octantInset(vec2(q.x, q.y + c), halfSize.x, along, centers.xy);
}
return octantInset(vec2(q.y, q.x - c), halfSize.y, across, centers.zw);
}
// Adds one sample of the profile to the radius at turn u and its slope, by
// the Catmull-Rom (Keys) kernel, which crosses the samples smoothly.
void addTurn(float u, float index, float radius, inout vec2 sum) {
float x = u - index;
x -= kTurns * floor(x / kTurns + 0.5);
float d = abs(x);
if (d >= 2.0) {
return;
}
vec2 weight = d < 1.0
? vec2((1.5 * d - 2.5) * d * d + 1.0, (4.5 * d - 5.0) * d)
: vec2(((-0.5 * d + 2.5) * d - 4.0) * d + 2.0, (-1.5 * d + 5.0) * d - 4.0);
sum += radius * vec2(weight.x, weight.y * sign(x));
}
// The distance inward from a star-shaped outline and its outward normal,
// both taken at the outline where the point's direction from the center
// meets it, so they hold steady all the way in.
void outlineGeometry(vec2 p, out float inset, out vec2 normal) {
float len = max(length(p), 1e-3);
float turn = atan(p.y, p.x) / kTau;
float u = (turn - floor(turn)) * kTurns;
vec2 sum = vec2(0.0);
for (int i = 0; i < 16; i++) {
vec4 radii = uProfile[i];
float index = float(i) * 4.0;
addTurn(u, index, radii.x, sum);
addTurn(u, index + 1.0, radii.y, sum);
addTurn(u, index + 2.0, radii.z, sum);
addTurn(u, index + 3.0, radii.w, sum);
}
float reach = max(sum.x, 1e-3);
float slope = sum.y * kTurns / kTau / reach;
vec2 radial = p / len;
vec2 gradient = radial - slope * vec2(-radial.y, radial.x);
float stretch = length(gradient);
inset = max((reach - len) / stretch, 0.0);
normal = gradient / stretch;
}
// The highlight's bands over depths a to b in from the rim, on average.
float shineAcross(float a, float b) {
float sum = 0.0;
float start = 0.0;
for (int i = 0; i < 3; i++) {
vec4 ends = uShineEnds[i];
vec4 starts = vec4(start, ends.xyz);
sum += dot(
uShineShares[i],
max(min(vec4(b), ends) - max(vec4(a), starts), 0.0)
);
start = ends.w;
}
return sum / max(b - a, 1e-4);
}
// The highlight's brightness a turn from the vertical, as the sweep the
// fallback draws it with lays it out.
float shineAround(float turn) {
vec4 startTurns = uShineTurns[0];
vec4 lastTurns = uShineTurns[1];
vec4 startLit = uShineLit[0];
vec4 lastLit = uShineLit[1];
vec4 firstEnds = vec4(startTurns.yzw, lastTurns.x);
vec3 lastEnds = lastTurns.yzw;
vec4 firstRise = vec4(startLit.yzw, lastLit.x) - startLit;
vec3 lastRise = lastLit.yzw - lastLit.xyz;
return startLit.x
+ dot(firstRise, clamp(
(turn - startTurns) / max(firstEnds - startTurns, 1e-5), 0.0, 1.0))
+ dot(lastRise, clamp(
(turn - lastTurns.xyz) / max(lastEnds - lastTurns.xyz, 1e-5),
0.0,
1.0
));
}
void main() {
vec2 point = uFrame.xy + FlutterFragCoord().xy * uFrame.zw;
vec2 halfSize = uShape.xy * 0.5;
float halfMinor = min(halfSize.x, halfSize.y);
vec2 centered = point - halfSize;
float inset;
vec2 normal;
// The inset of the line and the highlight. A pane is clipped to the engine's
// superellipse, up to half a dp inside its circular corners where a long side
// runs into them, and they keep to it; the optics read deeper than its inset
// holds true, so they keep the circles. Only the band the line and the
// highlight can reach works it out.
float edge;
if (uOutlined > 0.5) {
outlineGeometry(centered, inset, normal);
edge = inset;
} else {
vec2 side = step(0.0, centered);
float radius = min(
mix(
mix(uCorners.x, uCorners.y, side.x),
mix(uCorners.w, uCorners.z, side.x),
side.y
),
halfMinor
);
inset = max(-roundedBoxDistance(centered, halfSize, radius), 0.0);
normal = roundedBoxNormal(centered, halfSize, radius);
edge = inset;
bool lit = uEdge.x > 0.0 || uRim.w > 0.0 || uLight.x > 0.0;
float traced = max(
lit ? kLine + uLight.y : 0.0,
uShineLit[0].x > 0.0 ? uShineEnds[2].w : 0.0
);
if (traced > 0.0 && inset < traced + uTexels.x + kCornerGap * radius) {
edge = max(cornerInset(centered, halfSize), 0.0);
}
}
bool lens = uOptics.x < 0.0;
float bevel = max(min(uShape.z, halfMinor * 0.5), 1e-3);
float rise = 1.0 - clamp(inset / bevel, 0.0, 1.0);
vec2 shift;
if (lens) {
// As measured on iOS 27, the raised lens reads outward along its normal
// all around, by nothing at the rim and its full depth a bevel in, so its
// outer band draws out what lies right under the rim; the depth then eases
// off toward the middle.
shift = normal * -uOptics.x * smoothstep(0.0, bevel, inset)
* (1.0 - smoothstep(bevel, halfMinor, inset));
} else {
// A bevel of circular profile, fitted to small panes: flat where it meets
// the face and steepening toward the rim, which reads from so far inside
// that its outermost band shows the content squeezed and mirrored.
float depth = min(uOptics.x, halfMinor) * (1.0 - sqrt(1.0 - rise * rise));
shift = -normal * depth;
}
float frost = uShape.w + uEdge.z * rise * rise;
vec4 base = sampleFrosted(point + shift, frost);
if (uEdge.y > 0.0 && inset < kPartReach) {
// iOS 27 parts red from blue along the normal's mirror image, across it
// at the sides and along it at the corners. Along the top and bottom the
// parting narrows from the rim to what it holds around the ends. Over
// clear glass a channel shows past the others, so the cover is the widest
// of them.
vec2 part = normal.yx * 0.5 * uEdge.y
* (0.6 + 0.4 * abs(normal.y) * max(1.0 - inset / kPartHold, 0.0))
* (1.0 - smoothstep(kPartHold, kPartReach, inset));
vec4 red = sampleFrosted(point + shift + part, frost);
vec4 blue = sampleFrosted(point + shift - part, frost);
base = vec4(red.r, base.g, blue.b, max(base.a, max(red.a, blue.a)));
}
if (uHaze > 0.0) {
base = mix(base, sampleHaze(point + shift), uHaze);
}
float alpha = base.a;
vec3 color = base.rgb / max(alpha, 1e-4);
color = mix(vec3(dot(color, kLuma)), color, uOptics.y) * uOptics.z;
color *= 1.0 - uLight.z * abs(normal.x) * exp(-inset / kEndShade);
float clash = 0.0;
if (uGlow.w != 0.0) {
vec4 behind = sampleBehind(halfSize)
+ sampleBehind(halfSize * vec2(0.4, 0.6))
+ sampleBehind(halfSize * vec2(1.6, 0.6))
+ sampleBehind(halfSize * vec2(0.4, 1.4))
+ sampleBehind(halfSize * vec2(1.6, 1.4));
float behindLuma = dot(behind.rgb / max(behind.a, 1e-4), kLuma);
clash = clamp(
(sign(uGlow.w) * (dot(uTint.rgb, kLuma) - behindLuma) - 0.3) / 0.5,
0.0,
1.0
);
}
float cover = uTint.a + (1.0 - uTint.a) * clash * abs(uGlow.w);
float coverage = alpha * (1.0 - cover) + cover;
vec3 glass = color * alpha * (1.0 - cover) + uTint.rgb * cover;
// iOS lights the top and bottom of its glass, just inside the line, and on
// its tab bar fades that around the rounded ends with the normal's upward
// part squared, out only at their tips; its controls dim far faster. All
// screened toward white, a pale tint would clip into a flat white ring.
// The tail reads the circular inset, as it reaches past where the edge's is
// worked out, and moves too slowly for the difference to show.
float within = max(edge - kLine, 0.0);
float facing;
if (lens) {
facing = smoothstep(0.65, 0.95, abs(normal.y));
} else {
float rise2 = normal.y * normal.y;
float rise4 = rise2 * rise2;
float rise16 = rise4 * rise4 * rise4 * rise4;
facing = mix(rise2, 0.5 * rise2 * abs(normal.y) * (1.0 + rise16), uRim.y);
}
float band = clamp(1.0 - within / max(uLight.y, 1e-3), 0.0, 1.0);
float tail = clamp(1.0 - max(inset - kLine, 0.0) / kRimTail, 0.0, 1.0);
float shine = min(uLight.x * facing * mix(band, tail, uRim.x), 1.0);
glass += (vec3(coverage) - (1.0 - uRim.z) * glass) * shine;
// iOS 27 outlines its glass with a dark line, which UIKit draws just outside
// the bounds, over the backdrop, where the pane cannot draw, so it takes the
// outermost pixels here. iOS draws it darkest at the sides; the dock keeps
// it as faint all around as along the top, as its darker sides read as
// brackets.
float line = clamp((kLine - edge) / uTexels.x + 0.5, 0.0, 1.0);
float across = abs(normal.x);
float darken = mix(uEdge.x, uRim.w, across * across * across);
if ((uEdge.x > 0.0 || uRim.w > 0.0) && line > 0.0) {
vec4 under = sampleGlass(point);
vec3 dark = max(under.rgb - kLineCap * under.a, 0.22 * under.rgb);
glass = mix(glass, mix(under.rgb, dark, darken), line);
coverage = mix(coverage, under.a, line);
}
vec2 toGlow = point - uGlow.xy;
float reach = max(uGlowSpread.x * uGlowSpread.x, 1.0);
glass += uGlow.z * coverage
* mix(exp(-dot(toGlow, toGlow) / reach), 1.0, uGlowSpread.y);
// A pixel the rim crosses takes the bands it covers, as the rasterizer
// weighs the rest by its coverage.
if (uShineLit[0].x > 0.0 && edge < uShineEnds[2].w + uTexels.x) {
float turn = atan(abs(centered.x), abs(centered.y));
float pixel = 0.5 * uTexels.x;
glass += coverage * shineAround(turn)
* shineAcross(max(edge - pixel, 0.0), edge + pixel);
}
fragColor = vec4(min(glass, vec3(coverage)), coverage);
}