perf(effects): keep the theme animation clock in double precision

The theme effects stored their animation time in a 32-bit float taken
from the ImGui clock. After a few days of uptime a float can no longer
resolve a single frame step, so every oscillation, drift and sweep began
to stutter, snapping forward a frame at a time instead of moving
smoothly.

- The clock is now held as a double, and the phase/oscillator maths runs
  through a small set of shared helpers that reduce the argument in
  double before returning a float. A float clock at 30 days steps by a
  quarter second per ULP; the double clock stays frame-accurate.
- Hue cycling, the rainbow and gradient borders, shimmer, specular
  glare, glow pulse, edge trace, embers, the sandstorm and the viewport
  wash all use the shared helpers, so their timing no longer drifts with
  uptime.

Tests: the helpers advance smoothly after seven days, pin the legacy
float breakdown at 30 days, and stay in range for negative offsets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 13:30:20 -05:00
co-authored by Claude Opus 4.8
parent 944fafa1c7
commit fbc429f914
4 changed files with 82 additions and 28 deletions
+34
View File
@@ -0,0 +1,34 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <cmath>
namespace dragonx { namespace ui { namespace effects {
constexpr double kEffectTwoPi = 6.283185307179586476925286766559;
/// Fractional part in [0,1) of t*rate + offset, evaluated in double so the result stays
/// smooth after days of uptime (a float clock steps at one-frame granularity from ~3 days).
inline double cyclePhaseD(double t, double rate, double offset = 0.0) {
double p = std::fmod(t * rate + offset, 1.0);
if (p < 0.0) p += 1.0;
if (p >= 1.0) p = 0.0; // p was -tiny: p + 1.0 rounded up to 1.0
return p;
}
inline float cyclePhase(double t, double rate, double offset = 0.0) {
return static_cast<float>(cyclePhaseD(t, rate, offset));
}
/// sin(2*pi*(t*cycles + offset)) - oscillator whose speed is in cycles per second.
inline float cycleSin(double t, double cycles, double offset = 0.0) {
return static_cast<float>(std::sin(kEffectTwoPi * cyclePhaseD(t, cycles, offset)));
}
/// sin/cos(t*omega + offset), omega in rad/s; argument reduced in double.
inline float radSin(double t, double omega, double offset = 0.0) {
return static_cast<float>(std::sin(std::fmod(t * omega + offset, kEffectTwoPi)));
}
inline float radCos(double t, double omega, double offset = 0.0) {
return static_cast<float>(std::cos(std::fmod(t * omega + offset, kEffectTwoPi)));
}
}}} // namespace
+25 -27
View File
@@ -4,6 +4,7 @@
#include "theme_effects.h"
#include "low_spec.h"
#include "effect_clock.h"
#include "../schema/ui_schema.h"
#include "../layout.h"
#include <cmath>
@@ -47,7 +48,7 @@ ThemeEffects& ThemeEffects::instance() {
// ============================================================================
void ThemeEffects::beginFrame() {
if (!motion_frozen_) time_ = (float)ImGui::GetTime();
if (!motion_frozen_) time_ = ImGui::GetTime();
ImGuiViewport* vp = ImGui::GetMainViewport();
vpMinY_ = vp->WorkPos.y;
vpMaxY_ = vp->WorkPos.y + vp->WorkSize.y;
@@ -248,7 +249,7 @@ void ThemeEffects::loadFromTheme() {
ImU32 ThemeEffects::getAccentColor(float phaseOffset) const {
if (!enabled_ || !hue_cycle_.enabled) return IM_COL32(255, 218, 0, 255);
float hue = std::fmod(hue_cycle_.offset + time_ * hue_cycle_.speed + phaseOffset, 1.0f);
float hue = cyclePhase(time_, hue_cycle_.speed, (double)hue_cycle_.offset + phaseOffset);
if (hue < 0.0f) hue += 1.0f;
// Clamp to configured range
hue = hue_cycle_.offset + std::fmod(hue, std::max(0.01f, hue_cycle_.range));
@@ -293,7 +294,7 @@ void ThemeEffects::drawRainbowBorder(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding, float thickness) const {
if (!enabled_ || !rainbow_border_.enabled || rainbow_border_.stops.empty()) return;
float t = time_ * rainbow_border_.speed;
float t = cyclePhase(time_, rainbow_border_.speed);
// Draw the rainbow border as line segments around the rounded perimeter.
// Each segment gets a color sampled from the gradient at its position,
@@ -338,7 +339,7 @@ void ThemeEffects::drawShimmer(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
// Sweep position: band moves left to right across the panel
float period = 1.0f / std::max(0.01f, shimmer_.speed);
float totalTravel = w + shimmer_.width * 2.0f; // band enters and exits
float pos = std::fmod(time_ / period, 1.0f) * totalTravel - shimmer_.width;
float pos = cyclePhase(time_, 1.0 / period) * totalTravel - shimmer_.width;
// Band bounds (horizontal, ignoring angle for simplicity in this version)
float bandLeft = pMin.x + pos;
@@ -408,10 +409,10 @@ void ThemeEffects::drawSpecularGlare(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float phaseSeed = seed + s * 3.14159265f;
// Slow Lissajous drift — each spot follows a unique orbit
float freqX = 2.0f * 3.14159265f * specular_glare_.speed;
float freqY = freqX * 1.618f; // golden ratio offset → non-repeating path
float driftX = std::sin(time_ * freqX + phaseSeed) * 0.3f + 0.5f;
float driftY = std::cos(time_ * freqY + phaseSeed * 1.3f) * 0.25f + 0.4f;
const double freqX = kEffectTwoPi * specular_glare_.speed;
const double freqY = freqX * 1.618; // golden ratio offset → non-repeating path
float driftX = radSin(time_, freqX, phaseSeed) * 0.3f + 0.5f;
float driftY = radCos(time_, freqY, phaseSeed * 1.3) * 0.25f + 0.4f;
float cx = pMin.x + w * driftX;
float cy = pMin.y + h * driftY;
@@ -484,7 +485,7 @@ void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
if (!enabled_ || !glow_pulse_.enabled) return;
// Sinusoidal oscillation
float phase = std::sin(time_ * glow_pulse_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
float phase = cycleSin(time_, glow_pulse_.speed) * 0.5f + 0.5f;
float alpha = glow_pulse_.minAlpha + phase * (glow_pulse_.maxAlpha - glow_pulse_.minAlpha);
if (alpha <= 0.001f) return;
@@ -525,8 +526,7 @@ void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 p
// Smooth sinusoidal oscillation between color A and color B.
// phaseOffset shifts where in the cycle this element sits so a wall of
// panels reads like veins at different depths rather than one pulse.
float phase = std::sin((time_ * gradient_border_.speed + phaseOffset)
* 2.0f * 3.14159265f) * 0.5f + 0.5f;
float phase = cycleSin(time_, gradient_border_.speed, phaseOffset) * 0.5f + 0.5f;
// Extract RGBA from both colors and lerp
RGB ca = unpackRGB(gradient_border_.colorA);
@@ -649,7 +649,7 @@ void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || !edge_trace_.enabled) return;
float headPos = std::fmod(time_ * edge_trace_.speed, 1.0f);
float headPos = cyclePhase(time_, edge_trace_.speed);
float traceLen = edge_trace_.length;
RGB c = unpackRGB(edge_trace_.color);
@@ -705,12 +705,12 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
for (int i = 0; i < ember_rise_.count; i++) {
// Golden-ratio spacing gives evenly distributed phases
float phase = std::fmod(time_ * ember_rise_.speed + i * 0.618033988f, 1.0f);
float phase = cyclePhase(time_, ember_rise_.speed, i * 0.618033988);
// Deterministic pseudo-random x position per particle
// Simple hash: sin of large prime multiples
float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f;
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f * dp; // gentle sway
float xDrift = radSin(time_, 0.7, i * 2.4) * 4.0f * dp; // gentle sway
float x = pMin.x + w * xHash + xDrift;
float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top
@@ -763,13 +763,13 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
float vpAlpha = ember_rise_.alpha * 0.4f * bgOpacity_; // softer than panel embers
for (int i = 0; i < vpCount; i++) {
float phase = std::fmod(time_ * ember_rise_.speed * 0.6f + i * 0.618033988f, 1.0f);
float phase = cyclePhase(time_, ember_rise_.speed * 0.6, i * 0.618033988);
// Deterministic x position spread across viewport
float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
xHash = xHash - (int)xHash; // fractional part
if (xHash < 0) xHash += 1.0f;
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f * dp;
float xDrift = radSin(time_, 0.5, i * 1.7) * 8.0f * dp;
float x = vpX + vpW * xHash + xDrift;
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top
@@ -815,8 +815,7 @@ void ThemeEffects::drawSandstorm(ImDrawList* dl) const {
float windDy = std::sin(windRad); // vertical component (positive = downward)
// Global gust modulation: smoothly varies wind speed
float gust = 1.0f + sandstorm_.gustStrength *
std::sin(time_ * sandstorm_.gustSpeed * 2.0f * 3.14159265f);
float gust = 1.0f + sandstorm_.gustStrength * cycleSin(time_, sandstorm_.gustSpeed);
// Diagonal traversal distance (how far a particle travels across viewport)
float traversal = vpW + vpH * std::abs(windDy / std::max(0.01f, windDx));
@@ -844,14 +843,13 @@ void ThemeEffects::drawSandstorm(ImDrawList* dl) const {
// Phase: horizontal progress across viewport (0→1)
// Different particles have staggered starts via golden ratio
float phase = std::fmod(
time_ * sandstorm_.speed * speedMul * gust + i * 0.618033988f, 1.0f);
float phase = cyclePhase(time_, (double)sandstorm_.speed * speedMul * gust, i * 0.618033988);
// Y position: distributed across viewport with turbulent drift
float yBase = vpY + vpH * hash2;
float yTurb = std::sin(time_ * 0.8f + i * 3.7f) * vpH * 0.04f;
float yTurb = radSin(time_, 0.8, i * 3.7) * vpH * 0.04f;
// Additional gust-correlated vertical shift
float yGust = std::sin(time_ * sandstorm_.gustSpeed * 6.28318f + i * 1.3f) * vpH * 0.02f;
float yGust = radSin(time_, sandstorm_.gustSpeed * 6.28318, i * 1.3) * vpH * 0.02f;
// Position: particle blows right-to-left with downward drift
float x = vpX + vpW * (1.0f - phase) + phase * traversal * (1.0f - windDx) * 0.1f;
@@ -935,7 +933,7 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
posKey = posKey - (int)posKey; // fractional 0..1
if (posKey < 0) posKey += 1.0f;
float headPos = std::fmod(time_ * edge_trace_.speed + posKey, 1.0f);
float headPos = cyclePhase(time_, edge_trace_.speed, posKey);
float traceLen = edge_trace_.length;
RGB c = unpackRGB(edge_trace_.color);
@@ -988,9 +986,9 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
ImVec2(pMax.x + 2, pMax.y + 2), true);
for (int i = 0; i < panelCount; i++) {
float phase = std::fmod(time_ * ember_rise_.speed + i * 0.618033988f + seed, 1.0f);
float phase = cyclePhase(time_, ember_rise_.speed, i * 0.618033988 + seed);
float xHash = std::sin((float)(i + 1) * 127.1f + seed * 100.0f) * 0.5f + 0.5f;
float xDrift = std::sin(time_ * 0.7f + i * 2.4f + seed) * 3.0f;
float xDrift = radSin(time_, 0.7, i * 2.4 + seed) * 3.0f;
float x = pMin.x + w * xHash + xDrift;
float y = pMax.y - phase * (h + 6.0f);
@@ -1038,7 +1036,7 @@ void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const {
// Rotation: smoothly shift which color appears at which corner
if (viewport_overlay_.washRotateSpeed > 0.001f) {
float shift = std::fmod(time_ * viewport_overlay_.washRotateSpeed, 1.0f);
float shift = cyclePhase(time_, viewport_overlay_.washRotateSpeed);
ImU32 originals[4] = { corners[0], corners[1], corners[2], corners[3] };
for (int c = 0; c < 4; c++) {
@@ -1068,7 +1066,7 @@ void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const {
// Smooth breathing via colour modulation (not alpha)
float colorMul = 1.0f;
if (viewport_overlay_.washPulseSpeed > 0.001f) {
float pulse = std::sin(time_ * viewport_overlay_.washPulseSpeed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
float pulse = cycleSin(time_, viewport_overlay_.washPulseSpeed) * 0.5f + 0.5f;
colorMul = 1.0f - viewport_overlay_.washPulseDepth * (1.0f - pulse);
}
+1 -1
View File
@@ -133,7 +133,7 @@ private:
bool enabled_ = true;
bool reduced_transparency_ = false;
float time_ = 0.0f;
double time_ = 0.0; ///< ImGui clock, kept double: float steps coarsely after days of uptime
bool motion_frozen_ = false; ///< Reduce Motion: beginFrame() leaves time_ alone
float bgOpacity_ = 1.0f; ///< window-opacity multiplier for effect alphas