Files
HellionChat/HellionChat/Ui/StyleEngine/AmbientParticles.cs
T
JonKazama-Hellion c1f1c0563c fix(style): tint the surface instead of repainting it
Both remaining complaints had one cause. The window paints its own background,
and the backdrop was filling the same area a second time. Two stacked layers
turn a translucent window solid, which is the density over the chat log.

And an opaque fill has to carry the entire gradient by itself. That is where the
banding came from: a shallow ramp across an opaque surface crosses so few 8-bit
values that each one covers a visible stripe. Nothing sits underneath to break
them up.

So it tints now. Near-transparent white at the top, near-transparent black at
the bottom, straight over whatever the window already drew. The window colour
stays visible, the ramp only shades it, and the game showing through disperses
what little stepping is left. The sidebar does the same with a flat black wash
rather than a repainted darker surface.

Motes are roughly half as bright everywhere, and the chat log takes 10% of that.

DrawVerticalGradient stays for the segmented control, which paints a surface
that genuinely is its own rather than one already drawn underneath.
2026-08-18 18:05:11 +02:00

119 lines
4.1 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using HellionChat.Util;
namespace HellionChat.Ui.StyleEngine;
// Slow motes drifting up behind a surface. Character Select+ does its fog with
// an image sequence; this is procedural instead, so there are no assets to ship,
// no texture handles to keep alive, and it takes the theme's accent colour
// rather than whatever was baked into a PNG.
//
// Deliberately faint and deliberately slow. The brief was "modern, not
// overloaded" -- at this opacity it reads as depth rather than as motion, and
// nothing here competes with the text on top of it.
internal sealed class AmbientParticles
{
private readonly struct Mote
{
public required float X { get; init; } // 0..1 across the surface
public required float Speed { get; init; } // fractions of height per second
public required float Radius { get; init; }
public required float Phase { get; init; } // sway offset, keeps them out of lockstep
public required float Sway { get; init; }
}
private readonly Mote[] _motes;
private readonly float[] _y;
private int _lastFrame = -1;
internal AmbientParticles(int count = 70, int seed = 0x48454C4C)
{
// Seeded rather than time-based: the layout is identical every session,
// so a screenshot taken today matches one taken tomorrow.
var rng = new Random(seed);
_motes = new Mote[count];
_y = new float[count];
for (var i = 0; i < count; i++)
{
_motes[i] = new Mote
{
X = (float)rng.NextDouble(),
Speed = 0.014f + (float)rng.NextDouble() * 0.030f,
Radius = 1.1f + (float)rng.NextDouble() * 2.6f,
Phase = (float)rng.NextDouble() * MathF.Tau,
Sway = 0.004f + (float)rng.NextDouble() * 0.010f,
};
_y[i] = (float)rng.NextDouble();
}
}
internal void Draw(
ImDrawListPtr dl,
Vector2 min,
Vector2 max,
uint accentAbgr,
float intensity = 1f
)
{
if (Plugin.Config.ReduceMotion)
return;
var size = max - min;
if (size.X <= 0f || size.Y <= 0f)
return;
// One advance per frame, not per call: the settings window draws this
// once, but a caller that draws two surfaces would otherwise run the
// simulation at double speed.
var frame = ImGui.GetFrameCount();
var advance = frame != _lastFrame;
if (advance)
_lastFrame = frame;
var dt = Math.Min(ImGui.GetIO().DeltaTime, 0.1f);
var time = (float)ImGui.GetTime();
for (var i = 0; i < _motes.Length; i++)
{
var m = _motes[i];
if (advance)
{
_y[i] -= m.Speed * dt;
if (_y[i] < -0.05f)
_y[i] += 1.1f;
}
var sway = MathF.Sin(time * 0.35f + m.Phase) * m.Sway;
var pos = new Vector2(min.X + (m.X + sway) * size.X, min.Y + _y[i] * size.Y);
if (pos.X < min.X || pos.X > max.X || pos.Y < min.Y || pos.Y > max.Y)
continue;
// Fades out at both ends of its travel, so motes appear and vanish
// instead of popping at the edge.
var edge = Math.Clamp(MathF.Min(_y[i], 1f - _y[i]) * 6f, 0f, 1f);
var pulse = 0.55f + 0.45f * MathF.Sin(time * 0.6f + m.Phase);
var radius = m.Radius * Metrics.Scale;
// Halo first, core on top. A flat disc at this size reads as a
// speck of dirt on the screen; the soft ring around it is what makes
// it look lit.
dl.AddCircleFilled(
pos,
radius * 2.1f,
ColourUtil.ApplyAlpha(accentAbgr, 0.075f * edge * pulse * intensity),
10
);
dl.AddCircleFilled(
pos,
radius,
ColourUtil.ApplyAlpha(accentAbgr, 0.20f * edge * pulse * intensity),
10
);
}
}
}