From 05b203c85c1ed9f7aa5d44e8202846e0081d49a0 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 18 Aug 2026 18:09:46 +0200 Subject: [PATCH] fix(style): dither the surface tint so it stops banding "Horizontal scanlines" names it exactly. An alpha ramp from 0x34 to 0 has 52 distinct values, so across a 600px pane each one owns a stripe about 12 pixels tall, and the eye reads those stripes as scanlines. Raising the contrast only buys more of them, thinner. So it dithers. Four ramps at a quarter alpha each, every one ending slightly lower than the last, so they share a starting colour but run at different slopes. Their step boundaries land on different rows, and where one layer has stepped up its neighbours have not, which puts the blend between two quantised values. Same total tint, four times the effective resolution, three extra draw calls. Layers are staggered by slope rather than offset on purpose: shifting them would leave a gap at the top where fewer layers overlap, which reads as a bright band. That trades one artefact for a worse one. The tint is roughly twice as strong now, which the settings pane wanted anyway, and strength is a parameter so the chat log can stay at 45% of it. --- .../Ui/StyleEngine/DrawListExtensions.cs | 63 +++++++++++++++++++ HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs | 17 +++-- HellionChat/Ui/Windows/MainWindow.cs | 2 +- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs index cd050a2..a92509f 100644 --- a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -120,6 +120,69 @@ internal static class DrawListExtensions dl.AddRectFilledMultiColor(min, max, top, top, bottom, bottom); } + // A vertical tint that does not band. + // + // The problem is quantisation, not the ramp: an alpha sweep from 26 to 0 + // only has 26 distinct values, so across 500 pixels each one owns a stripe + // roughly 20px tall -- read as horizontal scanlines. Raising the contrast + // just gives more, thinner stripes. + // + // The fix is to dither. Instead of one ramp, several at a fraction of the + // alpha each, every one offset by a sub-step of the height. Their stripe + // boundaries land at different rows, so where one layer steps up its + // neighbours have not yet, and the blend between them lands between two + // quantised values. Same total tint, layers times the effective resolution, + // for one extra draw call per layer. + public static void DrawDitheredVTint( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint topAbgr, + uint bottomAbgr, + int layers = 4 + ) + { + if (layers < 1) + layers = 1; + + var height = max.Y - min.Y; + if (height <= 0f) + return; + + // Every layer starts at the top edge and ends slightly lower than the + // last, so they share a starting colour but run at different slopes. + // 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++) + { + var stretch = band * (i / (float)layers); + dl.AddRectFilledMultiColor( + min, + new Vector2(max.X, max.Y + stretch), + top, + top, + bottom, + bottom + ); + } + } + + 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 // line boxes content in; a fading one suggests a boundary without drawing a // wall, which is the whole difference between a heading and a header bar. diff --git a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs index c833fae..cd82b0f 100644 --- a/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs +++ b/HellionChat/Ui/StyleEngine/SurfaceBackdrop.cs @@ -28,7 +28,12 @@ internal sealed class SurfaceBackdrop // Call right after entering a child, before its content. GetWindowDrawList // is then that child's own list, so everything lands behind the content // without a channel split. - internal void Draw(float accentWashHeight = 0.38f, float darken = 0f, float moteIntensity = 1f) + internal void Draw( + float accentWashHeight = 0.38f, + float darken = 0f, + float moteIntensity = 1f, + float strength = 1f + ) { var dl = ImGui.GetWindowDrawList(); var min = ImGui.GetWindowPos(); @@ -46,9 +51,13 @@ internal sealed class SurfaceBackdrop // visible underneath, the ramp only tints it, and the game showing // through breaks up any step that is left. var opacity = ((ImGui.GetColorU32(ImGuiCol.WindowBg) >> 24) & 0xFFu) / 255f; - var lift = 0x00FFFFFFu | ((uint)(0x0E * opacity) << 24); - var drop = 0x00000000u | ((uint)(0x1A * opacity) << 24); - dl.AddRectFilledMultiColor(min, max, lift, lift, drop, drop); + 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 + // dozen distinct values, so each owns a stripe tens of pixels tall, seen + // as horizontal scanlines across the pane. + dl.DrawDitheredVTint(min, max, lift, drop); if (darken > 0f) dl.AddRectFilled(min, max, (uint)(0xFF * darken * opacity) << 24); diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 9ad68c2..b162b93 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -389,7 +389,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // a settings pane, which is read in glances; a chat log is read // line by line, and anything drifting behind the text competes // with it. What is left is barely a texture. - _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f); + _backdrop.Draw(accentWashHeight: 0f, moteIntensity: 0.10f, strength: 0.45f); if (_activeTab is not null) _messages.Draw(_activeTab);