diff --git a/backend/src/glass/LiquidField.zig b/backend/src/glass/LiquidField.zig index b6c4c13a3..2b3c5e686 100644 --- a/backend/src/glass/LiquidField.zig +++ b/backend/src/glass/LiquidField.zig @@ -296,6 +296,12 @@ pub fn pack(self: *const LiquidField, covered: dvui.Rect.Physical, has_sharp: bo /// `liquid_glass`'s rim line width, in points. const rim_line_width: f32 = 0.8; +/// How many pixels the glass's edge coverage ramps over at `scale`: wider at 1x, where a one-pixel +/// ramp shows as stairs. The program's `aa`. +fn edgeRamp(scale: f32) f32 { + return if (scale <= 1.25) 1.4 else 1.0; +} + // ── Drawing ───────────────────────────────────────────────────────────────────────────────────── /// The program, written once in `shaders/liquid_glass.fragment.hlsl` and compiled from it for every @@ -619,7 +625,7 @@ pub fn sample(self: *const LiquidField, p: dvui.Point.Physical) Sample { const soft = @max(0, -f); return .{ .d = @max(d, in_place), - .coverage = std.math.clamp(0.5 - @max(d, in_place), 0, 1), + .coverage = std.math.clamp(0.5 - @max(d, in_place) / edgeRamp(self.scale), 0, 1), .out = .{ .x = out[0], .y = out[1] }, .steep = @exp(-soft / liquid_glass.depthPx(self.look(), rim)), .blur = std.math.lerp(m1[0], m2[0], wm), @@ -674,3 +680,17 @@ pub fn smin(a: f32, b: f32, k: f32, m: *f32) f32 { if (b < a) m.* = 1 - m.*; return @min(a, b) - h * h * k * 0.25; } + +test "the edge coverage ramps over 1.4 px at 1x and 1 px from 1.5x" { + var f: LiquidField = .{}; + f.add(.{ .rect = .{ .x = 0, .y = 0, .w = 100, .h = 100 }, .radii = @splat(10) }); + // Half a pixel outside the top edge, and half a pixel inside it. + const out: dvui.Point.Physical = .{ .x = 50, .y = -0.5 }; + const in: dvui.Point.Physical = .{ .x = 50, .y = 0.5 }; + f.scale = 1; + try std.testing.expectApproxEqAbs(0.5 - 0.5 / 1.4, f.sample(out).coverage, 1e-4); + try std.testing.expectApproxEqAbs(0.5 + 0.5 / 1.4, f.sample(in).coverage, 1e-4); + f.scale = 2; + try std.testing.expectEqual(@as(f32, 0), f.sample(out).coverage); + try std.testing.expectEqual(@as(f32, 1), f.sample(in).coverage); +} diff --git a/backend/src/glass/shaders/liquid_glass.fragment.hlsl b/backend/src/glass/shaders/liquid_glass.fragment.hlsl index 6f800140e..fe5eb3169 100644 --- a/backend/src/glass/shaders/liquid_glass.fragment.hlsl +++ b/backend/src/glass/shaders/liquid_glass.fragment.hlsl @@ -156,7 +156,12 @@ float4 main(PSInput input) : SV_Target0 float F = smin(f1, f2, k, wf); // Cut: the glass's outline, and so its rim line, runs along the place's where that is nearer. if (cutEdge) D = max(D, inPlace); - float cov = clamp(0.5 - max(D, inPlace), 0.0, 1.0); + // At scale 1 a one-pixel ramp reads as stairs, so the edge is spread over `aa` pixels (the + // rim below widens with it); from 1.5 the ramp is one pixel (`LiquidField.edgeRamp`). + float px = g2.z * 1.25; + bool lowRes = px <= 1.25; + float aa = lowRes ? 1.4 : 1.0; + float cov = clamp(0.5 - max(D, inPlace) / aa, 0.0, 1.0); if (cov <= 0.0) discard; if (input.color.r < 0.5) return float4(0.0, 0.0, 0.0, cov); @@ -273,11 +278,10 @@ float4 main(PSInput input) : SV_Target0 // Nothing across the band in either. Shade is the glass's own, not the picture's: a dark layer // over it, as whole where the picture is see-through as where it is not. `px` is a point, in // pixels (the rim line's width is 0.8 of one, `LiquidField.rim_line_width`). - float px = g2.z * 1.25; float af = abs(facing); bool diagonal = inward && dg > 0.0; float edge_dark = diagonal ? dg * (1.0 - smoothstep(0.2, 0.7, af)) * exp(-max(0.0, -D) / (2.0 * px)) - : g8.x * (1.0 - smoothstep(0.0, 0.5 * px, -D)); + : g8.x * (1.0 - smoothstep(0.0, lowRes ? 0.7 : 0.5 * px, -D)); // As much as the edge has formed (`lens`), as its bend, clearing and light are: glass that is // barely a lens yet — a drop sunk into its place's edge — draws no dark line round itself. float shade = inward ? max(g5.w * t * t, edge_dark) * min(lens, 1.0) : 0.0; @@ -286,7 +290,8 @@ float4 main(PSInput input) : SV_Target0 float spec = inward ? (diagonal ? dg * smoothstep(0.75, 1.0, af) : 0.25) : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; float line_ = diagonal ? exp(-max(0.0, -D) / px) - : inward ? smoothstep(0.25 * px, 0.5 * px, -D) * (1.0 - smoothstep(1.2 * px, 1.6 * px, -D)) + : inward ? (lowRes ? smoothstep(0.0, 0.9, -D) * (1.0 - smoothstep(1.5, 2.4, -D)) + : smoothstep(0.25 * px, 0.5 * px, -D) * (1.0 - smoothstep(1.2 * px, 1.6 * px, -D))) : exp(-max(0.0, -D) / g2.z); float lit = line_ * spec + (inward ? 0.0 : 0.04 * steepL * toward); // Down the shape (`v`, 0 at its top): the wash falls from `wash_top` to `wash_bottom`, and the diff --git a/core/dialogs.zig b/core/dialogs.zig index 7e0a384ca..86b483146 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -202,7 +202,7 @@ pub fn frostPaneKept(id: dvui.Id, rect: dvui.Rect.Physical, corners: dvui.Corner pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { const corners = rounding.round(rounding.card); if (!frostPane(id, r, corners, scale)) { - r.fill(corners.scale(scale, dvui.CornerRect.Physical), .{ .color = .{ .color = dialogFill() }, .fade = 1 }); + r.fill(corners.scale(scale, dvui.CornerRect.Physical), .{ .color = .{ .color = dialogFill() }, .fade = edgeFade(scale) }); } glassShadow(r, corners, scale, surfaceShadow(), 1); } @@ -387,11 +387,11 @@ pub fn glassBubbles(id: dvui.Id, bubbles: []const Bubble, scale: f32, opts: Bubb const physical = round.scale(scale, dvui.CornerRect.Physical); const piece = id.update(std.mem.asBytes(&i)); const frosted = if (opts.witness) |w| frostPaneKept(piece, b.rect, round, scale, w) else frostPane(piece, b.rect, round, scale); - if (!frosted) b.rect.fill(physical, .{ .color = .{ .color = if (opts.tint) |t| t.opacity(opts.mix) else dialogFill() }, .fade = 1 }); + if (!frosted) b.rect.fill(physical, .{ .color = .{ .color = if (opts.tint) |t| t.opacity(opts.mix) else dialogFill() }, .fade = edgeFade(scale) }); if (b.light >= bubble_press) { - b.rect.fill(physical, .{ .color = .{ .color = rowPress() }, .fade = 1 }); + b.rect.fill(physical, .{ .color = .{ .color = rowPress() }, .fade = edgeFade(scale) }); } else if (b.light > 0) { - b.rect.fill(physical, .{ .color = .{ .color = rowHover() }, .fade = 1 }); + b.rect.fill(physical, .{ .color = .{ .color = rowHover() }, .fade = edgeFade(scale) }); } } } @@ -417,7 +417,7 @@ pub fn dropSlot(r: dvui.Rect.Physical, scale: f32) void { const slot = r.insetAll(2 * scale); if (slot.w < 1 or slot.h < 1) return; const radius = @min(rounding.scaled(rounding.small) * scale, @min(slot.w, slot.h) / 2); - slot.fill(.round(radius), .{ .color = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(drop_slot_alpha) }, .fade = 1 }); + slot.fill(.round(radius), .{ .color = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(drop_slot_alpha) }, .fade = edgeFade(scale) }); } /// How much of the highlight a drop slot is: there without shouting over the tabs beside it. const drop_slot_alpha: f32 = 0.55; @@ -560,7 +560,7 @@ fn tooltipGlass(wd: *dvui.WidgetData, r: dvui.Rect.Physical, scale: f32, t: f32, const f = dialogFrost() orelse { const prect = r.insetAll(scale * bs.shrink).offsetPoint(bs.offset.scale(scale, dvui.Point.Physical)); prect.fill(phys_corners, .{ .color = .{ .color = bs.color.opacity(bs.alpha * p) }, .fade = scale * bs.fade }); - r.fill(phys_corners, .{ .color = .{ .color = dialogFill().opacity(p) }, .fade = 1 }); + r.fill(phys_corners, .{ .color = .{ .color = dialogFill().opacity(p) }, .fade = edgeFade(scale) }); return; }; // The shadow as a ring round the glass, after it (`glassShadow`), so the glass does not blur @@ -699,6 +699,17 @@ pub fn rowPress() dvui.Color { /// The style a row inside a floating surface takes its colours from. pub const row_style: dvui.Theme.Style.Name = .control; +/// The fade ring, in pixels, of a rounded fill at `scale`: wider at 1x, where one pixel steps. +fn edgeFade(scale: f32) f32 { + return if (scale <= 1.25) 1.5 else 1; +} + +test "a rounded fill's fade ring widens only on a 1x display" { + try std.testing.expectEqual(@as(f32, 1.5), edgeFade(1)); + try std.testing.expectEqual(@as(f32, 1), edgeFade(1.5)); + try std.testing.expectEqual(@as(f32, 1), edgeFade(2)); +} + /// The fill a dialog paints when there is no frost to composite with: the chrome at the /// dialog's opacity, lifted the same amount. pub fn dialogFill() dvui.Color {