Skip to content

Commit e10d34c

Browse files
jeandetclaude
andcommitted
fix(colormap2): follow pan via GPU translation instead of freezing
A spectrogram (QCPColorMap2) froze in place while the axes scrolled during a pan, snapping only when panning stopped — worst on large / log-Y energy spectrograms. QCPHistogram2D (ViewportIndependent) was unaffected. Two causes, two parts to the fix: 1. Engage the layer on pan. QCPColorMap2 is ViewportDependent: a range change kicks an async resample and the layer was only redrawn on the pipeline's finished signal. Interactive drags call markAffectedLayersDirty(), but a pan driven by set_range (axis synchronisation) does not — so the synced spectrogram was never marked dirty mid-drag and stayed frozen. Mark the layer dirty in onViewportChanged(). 2. Translate, don't repaint. Marking dirty alone would repaint the CPU staging buffer and re-upload the (viewport-sized) texture every frame — exactly what the skip-on-translate / Metal pan optimisation avoids for line graphs. Give QCPColorMap2 a stallPixelOffset() override so the compositor shifts the existing texture by the pan delta instead (no repaint, no re-upload), the same fast path QCPGraph2/QCPMultiGraph use. A fresh resample / data / gradient change forces a real redraw (guards on mapImageInvalidated). The zoom-rejection in stallPixelOffset is scale-aware (new qcp::axisRangeSizeRatio): a pure pan on a log axis changes the *linear* range size and would otherwise be misread as a zoom and rejected — which is why log-Y spectrograms were the worst affected. Tests (test-paintbuffer): pan dirties the colormap layer (fail before, pass after); stallPixelOffset is non-null on a pan incl. log-Y, and null on a zoom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b308450 commit e10d34c

5 files changed

Lines changed: 188 additions & 0 deletions

File tree

src/painting/viewport-offset.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,32 @@
22

33
#include <QPointF>
44
#include <QSize>
5+
#include <cmath>
56
#include <axis/axis.h>
67
#include <layoutelements/layoutelement-axisrect.h>
78

89
namespace qcp {
910

11+
// Scale-aware size ratio of an axis' current range vs a previously rendered
12+
// range. For a logarithmic axis a pure pan changes the *linear* range size
13+
// (e.g. [1,10]->[2,20] is 2x), so comparing linear sizes would misread a pan as
14+
// a zoom. Use log-space size on log axes so a pure pan yields ratio == 1.
15+
inline double axisRangeSizeRatio(const QCPAxis* axis, const QCPRange& renderedRange)
16+
{
17+
if (!axis)
18+
return 1.0;
19+
const QCPRange cur = axis->range();
20+
if (axis->scaleType() == QCPAxis::stLogarithmic
21+
&& cur.lower > 0 && cur.upper > 0
22+
&& renderedRange.lower > 0 && renderedRange.upper > 0)
23+
{
24+
const double oldLog = std::log(renderedRange.upper) - std::log(renderedRange.lower);
25+
const double curLog = std::log(cur.upper) - std::log(cur.lower);
26+
return oldLog != 0.0 ? curLog / oldLog : 1.0;
27+
}
28+
return renderedRange.size() != 0.0 ? cur.size() / renderedRange.size() : 1.0;
29+
}
30+
1031
inline QPointF computeViewportOffset(
1132
const QCPAxis* keyAxis, const QCPAxis* valueAxis,
1233
const QCPRange& oldKeyRange, const QCPRange& oldValueRange)

src/plottables/plottable-colormap2.cpp

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <axis/axis.h>
99
#include <layer.h>
1010
#include <datasource/resample.h>
11+
#include <painting/viewport-offset.h>
1112
#include <Profiling.hpp>
1213

1314
QCPColorMap2::QCPColorMap2(QCPAxis* keyAxis, QCPAxis* valueAxis)
@@ -231,6 +232,42 @@ void QCPColorMap2::onViewportChanged()
231232
if (!axisRect) return;
232233

233234
mPipeline.onViewportChanged(ViewportParams::fromAxes(mKeyAxis.data(), mValueAxis.data()));
235+
236+
// Dirty the layer on every viewport change so a pan is handled even when it
237+
// arrives via set_range (axis sync) rather than the interactive drag path
238+
// (which already calls markAffectedLayersDirty). stallPixelOffset() then lets
239+
// the compositor translate the existing texture instead of repainting it.
240+
if (mLayer)
241+
mLayer->markDirty();
242+
}
243+
244+
QPointF QCPColorMap2::stallPixelOffset() const
245+
{
246+
// Only valid when there is a rendered image to shift and no fresher one is
247+
// pending (a finished resample / data / gradient change must redraw, not
248+
// translate the stale texture).
249+
if (!mHasRenderedRange || !mKeyAxis || !mValueAxis
250+
|| mRenderer.mapImage().isNull() || mRenderer.mapImageInvalidated())
251+
return {};
252+
253+
// Pure translation only: reject genuine zoom. Scale-aware so a pure pan on a
254+
// log axis (which changes the linear range size) is not misread as a zoom.
255+
if (qAbs(qcp::axisRangeSizeRatio(mKeyAxis.data(), mRenderedKeyRange) - 1.0) > 1e-4
256+
|| qAbs(qcp::axisRangeSizeRatio(mValueAxis.data(), mRenderedValueRange) - 1.0) > 1e-4)
257+
return {};
258+
259+
const QPointF offset = qcp::computeViewportOffset(
260+
mKeyAxis.data(), mValueAxis.data(), mRenderedKeyRange, mRenderedValueRange);
261+
262+
// Reject if panned beyond the axis rect (texture no longer covers viewport).
263+
const bool keyVert = mKeyAxis->orientation() == Qt::Vertical;
264+
const double keyDim = keyVert ? mKeyAxis->axisRect()->height() : mKeyAxis->axisRect()->width();
265+
const double valDim = keyVert ? mKeyAxis->axisRect()->width() : mKeyAxis->axisRect()->height();
266+
if (qAbs(keyVert ? offset.y() : offset.x()) > keyDim
267+
|| qAbs(keyVert ? offset.x() : offset.y()) > valDim)
268+
return {};
269+
270+
return offset;
234271
}
235272

236273
bool QCPColorMap2::canProduceContent() const
@@ -277,6 +314,11 @@ void QCPColorMap2::draw(QCPPainter* painter)
277314
applyDefaultAntialiasingHint(painter);
278315
mRenderer.draw(painter, mKeyAxis.data(), mValueAxis.data(), keyRange, valueRange);
279316

317+
// Baseline for stallPixelOffset: the axes this image was just drawn against.
318+
mRenderedKeyRange = mKeyAxis->range();
319+
mRenderedValueRange = mValueAxis->range();
320+
mHasRenderedRange = true;
321+
280322
bool contoursActive = !mContourLevels.isEmpty() || mAutoContourCount > 0;
281323
if (contoursActive && (imageWasInvalidated || mContourCacheGen != mContourDataGen))
282324
updateContourGpu(resampledData);

src/plottables/plottable-colormap2.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class QCP_LIB_DECL QCPColorMap2 : public QCPAbstractPlottable
6969
QCPColormapPipeline& pipeline() { return mPipeline; }
7070
const QCPColormapPipeline& pipeline() const { return mPipeline; }
7171

72+
// Pure-pan pixel offset of the last-rendered image vs the current axes, so
73+
// the compositor can translate the existing texture during a pan instead of
74+
// repainting + re-uploading every frame (skip-on-translate fast path).
75+
[[nodiscard]] QPointF stallPixelOffset() const override;
76+
[[nodiscard]] bool hasRenderedRange() const { return mHasRenderedRange; }
77+
7278
// Contour overlay
7379
void setContourLevels(const QVector<double>& levels);
7480
[[nodiscard]] QVector<double> contourLevels() const { return mContourLevels; }
@@ -114,6 +120,10 @@ public Q_SLOTS:
114120
QCPColormapPipeline mPipeline;
115121
QCPColormapRenderer mRenderer;
116122

123+
// Axis ranges at the last full draw — baseline for the pan translation offset.
124+
bool mHasRenderedRange = false;
125+
QCPRange mRenderedKeyRange, mRenderedValueRange;
126+
117127
// Contour overlay
118128
QVector<double> mContourLevels;
119129
QPen mContourPen{Qt::white, 1.0};

tests/auto/test-paintbuffer/test-paintbuffer.cpp

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
#include <vector>
12
#include "test-paintbuffer.h"
3+
#include <painting/viewport-offset.h>
24

35
void TestPaintBuffer::init()
46
{
@@ -388,3 +390,110 @@ void TestPaintBuffer::skipRepaint_bufferNotReuploadedOnPan()
388390
// because draw() was NOT called (mRenderedRange not updated)
389391
QVERIFY(!mainLayer->pixelOffset().isNull());
390392
}
393+
394+
void TestPaintBuffer::colormap2_panDirtiesLayerBuffer()
395+
{
396+
// A ViewportDependent colormap resamples asynchronously on pan. It must
397+
// still dirty its layer on every viewport change so the existing image
398+
// redraws repositioned to the current axes — otherwise the spectrogram
399+
// freezes in place while the axes scroll until the resample lands.
400+
auto* cm = new QCPColorMap2(mPlot->xAxis, mPlot->yAxis);
401+
mPlot->xAxis->setRange(0, 10);
402+
mPlot->yAxis->setRange(0, 100);
403+
std::vector<double> x{0, 5, 10}, y{0, 50, 100};
404+
std::vector<double> z(x.size() * y.size(), 1.0);
405+
cm->setData(std::move(x), std::move(y), std::move(z));
406+
407+
mPlot->replot(QCustomPlot::rpImmediateRefresh);
408+
409+
auto buf = cm->layer()->mPaintBuffer.toStrongRef();
410+
QVERIFY(buf);
411+
buf->setContentDirty(false); // clean slate (ignore any queued async redraw)
412+
413+
mPlot->xAxis->setRange(2, 12); // pure pan
414+
QVERIFY2(buf->contentDirty(),
415+
"colormap2 pan did not dirty its layer — spectrogram frozen during drag");
416+
}
417+
418+
void TestPaintBuffer::colormap2_panDirtiesLayerBufferLogY()
419+
{
420+
// Same, with a logarithmic value axis (energy spectrogram): a pure pan in
421+
// log space must still dirty the layer (regression for the reported stuck
422+
// log-Y spectrogram).
423+
auto* cm = new QCPColorMap2(mPlot->xAxis, mPlot->yAxis);
424+
mPlot->xAxis->setRange(0, 10);
425+
mPlot->yAxis->setScaleType(QCPAxis::stLogarithmic);
426+
mPlot->yAxis->setRange(1, 1000);
427+
std::vector<double> x{0, 5, 10}, y{1, 10, 100, 1000};
428+
std::vector<double> z(x.size() * y.size(), 1.0);
429+
cm->setData(std::move(x), std::move(y), std::move(z));
430+
431+
mPlot->replot(QCustomPlot::rpImmediateRefresh);
432+
433+
auto buf = cm->layer()->mPaintBuffer.toStrongRef();
434+
QVERIFY(buf);
435+
buf->setContentDirty(false);
436+
437+
mPlot->yAxis->setRange(2, 2000); // pure pan in log space (×2)
438+
QVERIFY2(buf->contentDirty(),
439+
"colormap2 log-Y pan did not dirty its layer — spectrogram frozen during drag");
440+
}
441+
442+
void TestPaintBuffer::colormap2_stallOffsetOnPan()
443+
{
444+
// Skip-on-translate: after a pan the colormap reports a pixel offset so the
445+
// compositor shifts the existing texture instead of repainting+re-uploading.
446+
auto* cm = new QCPColorMap2(mPlot->xAxis, mPlot->yAxis);
447+
mPlot->xAxis->setRange(0, 10);
448+
mPlot->yAxis->setRange(0, 100);
449+
std::vector<double> x{0, 5, 10}, y{0, 50, 100};
450+
std::vector<double> z(x.size() * y.size(), 1.0);
451+
cm->setData(std::move(x), std::move(y), std::move(z));
452+
(void)mPlot->toPixmap(400, 300); // synchronous draw establishes the rendered range
453+
QVERIFY(cm->hasRenderedRange());
454+
QCOMPARE(cm->stallPixelOffset(), QPointF(0, 0)); // no pan yet
455+
456+
const QCPRange oldKey(0, 10), oldVal(0, 100);
457+
mPlot->xAxis->setRange(2, 12); // pure pan
458+
const QPointF expected =
459+
qcp::computeViewportOffset(mPlot->xAxis, mPlot->yAxis, oldKey, oldVal);
460+
QVERIFY(!expected.isNull());
461+
QCOMPARE(cm->stallPixelOffset(), expected);
462+
QCOMPARE(mPlot->layer("main")->pixelOffset(), expected); // layer aggregates it
463+
}
464+
465+
void TestPaintBuffer::colormap2_stallOffsetOnLogYPan()
466+
{
467+
// A pure pan on a log Y axis must still translate (regression for the stuck
468+
// log-Y energy spectrogram): the zoom check is scale-aware.
469+
auto* cm = new QCPColorMap2(mPlot->xAxis, mPlot->yAxis);
470+
mPlot->xAxis->setRange(0, 10);
471+
mPlot->yAxis->setScaleType(QCPAxis::stLogarithmic);
472+
mPlot->yAxis->setRange(1, 1000);
473+
std::vector<double> x{0, 5, 10}, y{1, 10, 100, 1000};
474+
std::vector<double> z(x.size() * y.size(), 1.0);
475+
cm->setData(std::move(x), std::move(y), std::move(z));
476+
(void)mPlot->toPixmap(400, 300);
477+
QVERIFY(cm->hasRenderedRange());
478+
479+
mPlot->yAxis->setRange(2, 2000); // pure pan in log space (x2)
480+
QVERIFY2(!cm->stallPixelOffset().isNull(),
481+
"log-Y pan misread as zoom — colormap would repaint+reupload every frame");
482+
}
483+
484+
void TestPaintBuffer::colormap2_stallOffsetNullOnZoom()
485+
{
486+
// A genuine zoom must NOT translate (texture would be wrongly scaled); fall
487+
// back to a real repaint.
488+
auto* cm = new QCPColorMap2(mPlot->xAxis, mPlot->yAxis);
489+
mPlot->xAxis->setRange(0, 10);
490+
mPlot->yAxis->setRange(0, 100);
491+
std::vector<double> x{0, 5, 10}, y{0, 50, 100};
492+
std::vector<double> z(x.size() * y.size(), 1.0);
493+
cm->setData(std::move(x), std::move(y), std::move(z));
494+
(void)mPlot->toPixmap(400, 300);
495+
QVERIFY(cm->hasRenderedRange());
496+
497+
mPlot->xAxis->setRange(0, 20); // zoom out 2x
498+
QVERIFY(cm->stallPixelOffset().isNull());
499+
}

tests/auto/test-paintbuffer/test-paintbuffer.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ private slots:
3030
void setVisible_dirtiesLayerBuffer();
3131
void setVisible_noOpWhenUnchanged();
3232

33+
void colormap2_panDirtiesLayerBuffer();
34+
void colormap2_panDirtiesLayerBufferLogY();
35+
void colormap2_stallOffsetOnPan();
36+
void colormap2_stallOffsetOnLogYPan();
37+
void colormap2_stallOffsetNullOnZoom();
38+
3339
private:
3440
QCustomPlot* mPlot;
3541
};

0 commit comments

Comments
 (0)