From fbc429f914b2ee80b7a80f88b94e0ca97c377ca4 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 13 Oct 2026 13:30:20 -0500 Subject: [PATCH] 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 --- src/ui/effects/effect_clock.h | 34 +++++++++++++++++++++ src/ui/effects/theme_effects.cpp | 52 +++++++++++++++----------------- src/ui/effects/theme_effects.h | 2 +- tests/test_phase4.cpp | 22 ++++++++++++++ 4 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 src/ui/effects/effect_clock.h diff --git a/src/ui/effects/effect_clock.h b/src/ui/effects/effect_clock.h new file mode 100644 index 0000000..263f1ff --- /dev/null +++ b/src/ui/effects/effect_clock.h @@ -0,0 +1,34 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +#pragma once +#include + +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(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(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(std::sin(std::fmod(t * omega + offset, kEffectTwoPi))); +} +inline float radCos(double t, double omega, double offset = 0.0) { + return static_cast(std::cos(std::fmod(t * omega + offset, kEffectTwoPi))); +} + +}}} // namespace diff --git a/src/ui/effects/theme_effects.cpp b/src/ui/effects/theme_effects.cpp index cc2d2f1..63cb8ff 100644 --- a/src/ui/effects/theme_effects.cpp +++ b/src/ui/effects/theme_effects.cpp @@ -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 @@ -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); } diff --git a/src/ui/effects/theme_effects.h b/src/ui/effects/theme_effects.h index fc05fc4..ad72ad8 100644 --- a/src/ui/effects/theme_effects.h +++ b/src/ui/effects/theme_effects.h @@ -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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 979c8ee..403eefb 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -69,6 +69,7 @@ #include "ui/material/scroll_edge_fade.h" #include "ui/node_status_banner.h" #include "ui/staleness_badge.h" +#include "ui/effects/effect_clock.h" #include "ui/notifications.h" #include "data/seed_migration_resume.h" #include "data/pin_vault_policy.h" @@ -13492,6 +13493,26 @@ void testPoolFeeParsing() EXPECT_FALSE(ok); // string, not number } +void testEffectClock() { + using namespace dragonx::ui::effects; + const double t7d = 7 * 86400.0, dt = 1.0 / 60.0; + // smooth after 7 days: consecutive frames advance by rate*dt (0.3/60 = 0.005 cycle) + for (int k = 0; k < 120; ++k) { + float a = cyclePhase(t7d + k * dt, 0.3), b = cyclePhase(t7d + (k + 1) * dt, 0.3); + float d = b - a; if (d < -0.5f) d += 1.0f; // wrap + EXPECT_NEAR(d, 0.005f, 1e-4f); + } + // legacy float clock pins the bug: at 30 days one float ULP is 0.25 s + const double t30 = 30 * 86400.0; + EXPECT_TRUE((float)(t30 + 0.5 / 60.0) == (float)(t30 + 1.5 / 60.0)); + EXPECT_TRUE(cyclePhase(t30 + 0.5 / 60.0, 0.3) != cyclePhase(t30 + 1.5 / 60.0, 0.3)); + // range and negative offsets + EXPECT_NEAR(cyclePhase(1.0, 0.0, -0.25), 0.75f, 1e-6f); + for (double t = 0; t < 1e7; t += 123457.7) { float p = cyclePhase(t, 0.37, -0.1); EXPECT_TRUE(p >= 0.0f && p < 1.0f); } + EXPECT_NEAR(cycleSin(t7d + 0.25, 1.0), 1.0f, 1e-5f); + EXPECT_NEAR(radSin(1e6, 0.0, 1.5707963267948966), 1.0f, 1e-6f); +} + // The effective pool list = official pools ∪ saved favorites ∪ current custom pool, // endpoint-deduped, with synthetic user rows flagged official=false. void testEffectivePools() @@ -18227,6 +18248,7 @@ int main() testPoolHashrateParsing(); testPoolFeeParsing(); testEffectivePools(); + testEffectClock(); testFormatFeePercent(); testPoolWeightedSelection(); testCoalescingWriter();