fix(style): shorten the ramp instead of layering it

The dithering attempt made it worse and the reasoning was backwards. Four layers
at a quarter alpha each carry a quarter of the steps each, so every layer bands
more coarsely than the single ramp did, and blending four coarse ramps adds
interference on top. Reverted.

The real variable is distance, not layer count. A ramp has as many steps as it
has distinct alpha values, so stretching it over the full height of a pane gives
each step a stripe twenty-odd pixels tall, which the eye sharpens into
scanlines. Running the same ramp over 190 pixels puts those steps a few pixels
apart, where they read as a falloff.

So: light fading down from the top edge, shadow gathering at the bottom, and a
flat surface in between. Constant alpha cannot band at all, which leaves most of
the pane immune by construction.
This commit is contained in:
2026-08-18 18:14:02 +02:00
parent 05b203c85c
commit 7df18bd552
2 changed files with 41 additions and 52 deletions
@@ -120,67 +120,55 @@ internal static class DrawListExtensions
dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom); dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom);
} }
// A vertical tint that does not band. // A vertical tint that stays under the banding threshold.
// //
// The problem is quantisation, not the ramp: an alpha sweep from 26 to 0 // Banding is quantisation: an alpha ramp has as many steps as it has
// only has 26 distinct values, so across 500 pixels each one owns a stripe // distinct values, and across a tall pane each step owns a stripe tens of
// roughly 20px tall -- read as horizontal scanlines. Raising the contrast // pixels high. The eye then sharpens those edges into scanlines.
// just gives more, thinner stripes.
// //
// The fix is to dither. Instead of one ramp, several at a fraction of the // Splitting the ramp into faint layers makes it worse, not better -- each
// alpha each, every one offset by a sub-step of the height. Their stripe // layer carries a fraction of the alpha and therefore a fraction of the
// boundaries land at different rows, so where one layer steps up its // steps, so the stripes get coarser and their blend adds interference.
// neighbours have not yet, and the blend between them lands between two //
// quantised values. Same total tint, layers times the effective resolution, // What actually works is running the whole ramp over a short distance
// for one extra draw call per layer. // instead of a tall one. The same handful of steps then falls within a
public static void DrawDitheredVTint( // couple of hundred pixels, each stripe is a few pixels tall rather than
// twenty, and they read as a smooth falloff. Below the fade the surface is
// flat, where a constant alpha cannot band at all.
public static void DrawEdgeTint(
this ImDrawListPtr dl, this ImDrawListPtr dl,
Vector2 min, Vector2 min,
Vector2 max, Vector2 max,
uint topAbgr, uint edgeAbgr,
uint bottomAbgr, float fadeHeight,
int layers = 4 bool fromBottom = false
) )
{ {
if (layers < 1)
layers = 1;
var height = max.Y - min.Y; var height = max.Y - min.Y;
if (height <= 0f) if (height <= 0f)
return; return;
// Every layer starts at the top edge and ends slightly lower than the var fade = MathF.Min(fadeHeight, height);
// last, so they share a starting colour but run at different slopes. var clear = edgeAbgr & 0x00FFFFFFu;
// Shifting them instead would leave a gap at the top where fewer layers
// overlap, which reads as a bright band -- trading one artefact for a
// worse one.
//
// The stagger is a fraction of one quantisation band, so the steps
// interleave rather than landing on top of each other.
var steps = MathF.Max(1f, ((topAbgr >> 24) & 0xFFu) + ((bottomAbgr >> 24) & 0xFFu));
var band = height / steps;
var share = 1f / layers;
var top = ScaleAlpha(topAbgr, share);
var bottom = ScaleAlpha(bottomAbgr, share);
for (var i = 0; i < layers; i++) if (fromBottom)
{ dl.AddRectFilledMultiColor(
var stretch = band * (i / (float)layers); new Vector2(min.X, max.Y - fade),
max,
clear,
clear,
edgeAbgr,
edgeAbgr
);
else
dl.AddRectFilledMultiColor( dl.AddRectFilledMultiColor(
min, min,
new Vector2(max.X, max.Y + stretch), new Vector2(max.X, min.Y + fade),
top, edgeAbgr,
top, edgeAbgr,
bottom, clear,
bottom clear
); );
}
}
private static uint ScaleAlpha(uint abgr, float factor)
{
var a = (uint)Math.Clamp(MathF.Round(((abgr >> 24) & 0xFFu) * factor), 0f, 255f);
return (abgr & 0x00FFFFFFu) | (a << 24);
} }
// A rule that fades out along its length instead of stopping dead. A hard // A rule that fades out along its length instead of stopping dead. A hard
@@ -51,13 +51,14 @@ internal sealed class SurfaceBackdrop
// visible underneath, the ramp only tints it, and the game showing // visible underneath, the ramp only tints it, and the game showing
// through breaks up any step that is left. // through breaks up any step that is left.
var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f;
var lift = 0x00FFFFFFu | ((uint)(0x1E * strength * opacity) << 24);
var drop = 0x00000000u | ((uint)(0x34 * strength * opacity) << 24);
// Dithered, because a plain ramp bands: the alpha sweep has only a few // Two short fades rather than one tall ramp: light from the top edge,
// dozen distinct values, so each owns a stripe tens of pixels tall, seen // shadow gathering at the bottom, flat in between. A ramp stretched over
// as horizontal scanlines across the pane. // the full height is exactly the case that bands, because its handful of
dl.DrawDitheredVTint(min, max, lift, drop); // alpha steps each cover twenty-odd pixels.
var fade = MathF.Min(190f * Metrics.Scale, (max.Y - min.Y) * 0.45f);
dl.DrawEdgeTint(min, max, 0x00FFFFFFu | ((uint)(0x22 * strength * opacity) << 24), fade);
dl.DrawEdgeTint(min, max, (uint)(0x30 * strength * opacity) << 24, fade, fromBottom: true);
if (darken > 0f) if (darken > 0f)
dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24);