diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 2d0514ac..49684dc3 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -236,6 +236,13 @@ DWM magnifies with nearest neighbour unless something calls resulting shift per level. While smooth, the applied level snaps to the nearest level predicting under 1 px (never backwards in a ramp; held once the zoom settles). Measured standalone at 3840x2160: 10-25x jitter 11 px -> 0.7 px p95, worst jump 42 px -> 2 px; 2-10x 4.7 px -> 0.8 px. +- The ladder's cost is uneven speed: above ~6x clean levels are ~0.9 % apart while a tick moves ~3 %, + so single ticks run 15-40 % fast or slow (measured 2026-10-10: tick-to-tick rate change 8-21 % + median above 6x, against 1-5 % with the ladder off or nearest). Capped or rate-keeping snaps were + simulated against the trace and all brought most of the image jitter back, so the ladder stays. +- Release glide: by default the ease-out stops once it moves slower than clean levels are apart + (`EaseOutShouldStop`, about 10-20 % of full speed). `txGlideTail=1` glides it out to rest like + nearest, the slow tail unsnapped (`GlideTailUnsnapped`, a few small image jumps at the end). - Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image 1-2 px" verdict predates the working setter and is void. diff --git a/src/config.cpp b/src/config.cpp index 8ebc1468..4846f9a0 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -227,6 +227,7 @@ Config ParseConfig(const std::string& text) { else if (key == "txWarmHz") c.txWarmHz = std::stoi(val); else if (key == "mpoNearestGuard") c.mpoNearestGuard = std::stoi(val); else if (key == "txSmoothLadder") c.txSmoothLadder = std::stoi(val); + else if (key == "txGlideTail") c.txGlideTail = std::stoi(val); else if (key == "mpoGuardTest") c.mpoGuardTest = std::stoi(val); else if (key == "mpoGuard") c.mpoGuard = std::stoi(val); else if (key == "mpoGuardLiftWall") c.mpoGuardLiftWall = std::stoi(val); diff --git a/src/config.h b/src/config.h index 46fe5ed8..27ef11fc 100644 --- a/src/config.h +++ b/src/config.h @@ -302,6 +302,10 @@ struct Config { // levels where DWM's per-frame scratch rounding predicts under 1 px of movement (the zoom shake: // 11 px p95 -> 0.7 px measured at 10-25x). 0 = off (hot). int txSmoothLadder = 1; + // Release glide with the ladder (issue #425, src/zoom_ladder.h GlideTailUnsnapped): 0 (default) + // stops the ease-out once it moves slower than clean levels are apart; 1 glides it out to rest + // like nearest sampling, its slow tail unsnapped (a few small image jumps at the very end). Hot. + int txGlideTail = 0; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) int mpoGuard = 1; // diagnostic: 0 = never apply the guard effect (A/B on an MPO boot; hot) int mpoGuardLiftWall = 1; // no pan walls / write clamp / ghost while the session is plane-free (hot) diff --git a/src/main.cpp b/src/main.cpp index 66e31876..ebe1e251 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1338,12 +1338,24 @@ static void RunTick(TickState& t) { // AFTER the controller ticked: before it, level() still equals last frame's level, so the // "still moving" test never fired and the ease-out always ran its slow tail (#375). if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && + t.cfg.txGlideTail == 0 && // full glide (#425): the tail runs unsnapped instead t.zoom.level() != t.prevLvl && wind::EaseOutShouldStop(t.zoom.level(), t.prevLvl)) { if (auto* tmStop = dynamic_cast(t.model)) { const double shown = tmStop->writtenLevel(); if (shown > 1.001) { t.zoom.setLevel(shown); t.zoom.stopGlide(); } } } + // Full glide (#425): the tail eases into a rounding-cell edge and can rest short of where the + // controller's glide ended. Once the controller is still, it takes the level on screen, so + // the next zoom starts from what is shown rather than jumping to the controller's level. + if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && + t.cfg.txGlideTail != 0 && t.zoom.level() == t.prevLvl && t.zoom.level() > 1.001) { + if (auto* tmRest = dynamic_cast(t.model)) { + const double shown = tmRest->writtenLevel(); + if (shown > 1.001 && std::fabs(shown - t.zoom.level()) > t.zoom.level() * 1e-7) + t.zoom.setLevel(shown); + } + } } // Recenter on a recenterVk key press (rising edge). bool recenter = false; diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 5ea52a63..10c6edc9 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -66,6 +66,7 @@ void TransformModel::resetTransformState() { forceWrite_ = false; // lastLevel_ = 0 already forces the next write ixPubLevel_ = 0.0; ladderReq_ = 0.0; ladderOut_ = 0.0; + tailActive_ = false; tailDir_ = 0; lastPreLadder_ = 0.0; // Everything the write path caches must be forgotten across a teardown, or the next session // compares against values DWM no longer holds and skips the writes that would re-apply them. lastLevel_ = 0.0; lastRequestedLevel_ = 0.0; @@ -554,15 +555,32 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ladderOut_ = lastLevel_; applyLevel = lastLevel_; } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && - std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { + (tailActive_ || std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5))) { + // (A full-glide tail stopped short of its cell edge on purpose: keep it there too.) // The zoom just stopped: keep the level already on screen rather than re-snapping, so // releasing the key never nudges the zoom in or out (field 2026-10-07). ladderReq_ = level; ladderOut_ = lastLevel_; applyLevel = lastLevel_; + } else if (GlideTailUnsnapped(cfg.txGlideTail != 0, ex.zoomDriven, applyLevel, lastLevel_)) { + // Full release glide (#425): the slow tail is not snapped (a step smaller than the clean + // levels are apart can only hold and hop), and it never leaves the rounding cell it is + // in, where the image moves continuously: its progress eases into the cell edge. + const int dir = applyLevel > lastLevel_ ? 1 : -1; + if (!tailActive_ || dir != tailDir_) { + tailActive_ = true; + tailDir_ = dir; + tailStart_ = lastLevel_; + tailReqStart_ = lastPreLadder_ > 1.001 ? lastPreLadder_ : lastLevel_; + tailEdge_ = RoundingCellEdge(tailStart_, dir, r.centerX, r.centerY, mon_.w, mon_.h); + } + applyLevel = SoftApproach(tailStart_, tailEdge_, std::fabs(applyLevel - tailReqStart_)); + ladderReq_ = level; + ladderOut_ = applyLevel; } else { const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); - // (The slow tail of an ease-out never reaches here: RunTick stops the glide first.) + // (With txGlideTail=0 the slow tail of an ease-out never reaches here: RunTick stops the + // glide first.) const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); ladderReq_ = level; @@ -570,6 +588,8 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg applyLevel = snapped; } } + if (!(cfg.txGlideTail != 0 && !ex.zoomDriven)) tailActive_ = false; // a held zoom ends any tail + lastPreLadder_ = preLadderLevel; double srcL = r.srcLeft, srcT = r.srcTop; if (applyLevel != level) { OffsetF o = ComputeOffsetF(r.centerX, r.centerY, applyLevel, mon_.w, mon_.h); diff --git a/src/transform_model.h b/src/transform_model.h index 7456a776..3592de33 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -95,6 +95,10 @@ class TransformModel : public IMagnifierModel { unsigned long long lastGhostAssertMs_ = 0; // 500ms assert cadence int appliedSampling_ = -2; // sampling mode DWM currently holds (-2 = unknown) int sampleTryMode_ = -2; // sampling mode being attempted (#274) + // Full release glide tail (#425): eases into the rounding-cell edge instead of crossing it. + bool tailActive_ = false; + int tailDir_ = 0; + double tailStart_ = 0.0, tailEdge_ = 0.0, tailReqStart_ = 0.0, lastPreLadder_ = 0.0; unsigned long dwmGenSeen_ = 0; // DwmGeneration() last acted on (#396) int sampleTries_ = 0; // attempts so far for it (bounded at 3) unsigned long long sampleLastTryMs_ = 0; // when the last attempt ran diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index c34f16c4..4f286e4f 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -142,4 +142,49 @@ inline bool EaseOutShouldStop(double level, double prevLevel) { return step < 0.6 * SnapWindow(level); } +// FULL RELEASE GLIDE (issue #425, txGlideTail=1). The truncated ease-out above made a high-res +// release stop at 10-20 % of its speed while nearest sampling glides to rest (field 2026-10-10: +// "drives 50, then from 15 straight to 0"). With the option on, the glide is not cut; its slow tail +// (the part EaseOutShouldStop would cut) is written UNSNAPPED, because snapping a step smaller than +// the clean-level spacing can only hold and hop. The cost is the few DWM rounding steps the tail +// crosses, each a small image jump. Snapping still applies while a zoom key is held. +inline bool GlideTailUnsnapped(bool fullGlide, bool zoomDriven, double level, double prevLevel) { + return fullGlide && !zoomDriven && EaseOutShouldStop(level, prevLevel); +} + +// The last level reachable from z, moving in `dir`, without leaving z's rounding cell (z itself when +// dir is 0). Field 2026-10-10: the unsnapped tail shook the cursor exactly where it crossed DWM's +// rounding steps; inside the cell the image moves continuously. Searched within maxRel of z. +inline double RoundingCellEdge(double z, int dir, double centreX, double centreY, int w, int h, + double maxRel = 0.03) { + if (dir == 0 || z <= 1.001) return z; + const double step = z * 2e-4; + double prev = z; + for (double t = step; t <= z * maxRel; t += step) { + const double q = z + dir * t; + if (q <= 1.001) return prev; + if (!SameRoundingCell(z, q, centreX, centreY, w, h)) { + double a = prev, b = q; // a inside, b outside: bisect the step + for (int i = 0; i < 40; ++i) { + const double m = 0.5 * (a + b); + if (SameRoundingCell(z, m, centreX, centreY, w, h)) a = m; else b = m; + } + return a; + } + prev = q; + } + return z + dir * z * maxRel; +} + +// Map the tail's requested progress (level units travelled since the tail began, >= 0) onto a path +// that eases into `edge` and never reaches it: start + (edge - start) * (1 - e^(-progress / gap)). +// Its slope at the start is 1, so the hand-over from the glide keeps its speed; it slows smoothly as +// it nears the edge, so the glide decelerates to rest instead of stopping or crossing a step. +inline double SoftApproach(double start, double edge, double progress) { + const double gap = std::fabs(edge - start); + if (gap <= 0.0 || progress <= 0.0) return start; + const double s = edge > start ? 1.0 : -1.0; + return start + s * gap * (1.0 - std::exp(-progress / gap)); +} + } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 440e874a..d28c32a0 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -92,3 +92,32 @@ TEST_CASE("the ease-out runs while it moves faster than the clean-level spacing, CHECK(EaseOutShouldStop(25.0, 25.0 / 1.004) == true); // 0.4 % at 25x (window 1.2 %) CHECK(EaseOutShouldStop(1.0, 1.0) == false); } + +TEST_CASE("full release glide: only the released slow tail goes unsnapped (#425)") { + CHECK(GlideTailUnsnapped(true, false, 5.0, 5.0 / 1.001) == true); // released, in the tail + CHECK(GlideTailUnsnapped(true, false, 5.0, 5.0 / 1.02) == false); // released, still fast: snap + CHECK(GlideTailUnsnapped(true, true, 5.0, 5.0 / 1.001) == false); // key held: always snap + CHECK(GlideTailUnsnapped(false, false, 5.0, 5.0 / 1.001) == false); // option off: unchanged +} + +TEST_CASE("the glide tail eases into its rounding cell's edge and never crosses it (#425)") { + const int W = 3840, H = 2160; + const double cx = 1920.0, cy = 1080.0; + for (double z = 2.0; z < 30.0; z *= 1.137) { + for (int dir = -1; dir <= 1; dir += 2) { + const double edge = RoundingCellEdge(z, dir, cx, cy, W, H); + CHECK(SameRoundingCell(z, edge, cx, cy, W, H)); // edge is inside + CHECK_FALSE(SameRoundingCell(z, edge + dir * z * 1e-6, cx, cy, W, H)); // a hair further is not + double prev = z; + for (double p = 0.0; p <= z * 0.05; p += z * 0.0005) { + const double q = SoftApproach(z, edge, p); + CHECK(SameRoundingCell(z, q, cx, cy, W, H)); // never leaves the cell + CHECK((q - prev) * dir >= 0.0); // monotonic + prev = q; + } + } + } + // Hand-over keeps the glide's speed: slope 1 at the start. + CHECK(SoftApproach(5.0, 5.1, 1e-6) == doctest::Approx(5.0 + 1e-6).epsilon(1e-9)); + CHECK(SoftApproach(5.0, 4.9, 0.0) == 5.0); +}