Ω-W1-T3 review fixes: skip meter invalidate when covered; push fast-path predicates into core

Fixes the critical finding — Browse/curve-popup no longer trigger 60 Hz
whole-client repaints when the meter is invisible. Pushes the fast-path and
self-containment geometry into master_meter with tests; batches the four minor
findings.
This commit is contained in:
2026-08-03 13:31:50 -04:00
parent 4320bfdb08
commit dd067a192e
9 changed files with 102 additions and 21 deletions
+5 -1
View File
@@ -284,7 +284,11 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
a flag would paint a meter frame over a face that had really changed. What stays outside the
field stays on the full path: the static numeral gutter (AA text re-blended onto itself every
frame thickens) and the GR lamp (drawn straight onto the deck group's gradient, which a
sub-rect fill cannot reproduce), so a lamp transition takes a whole-client repaint.
sub-rect fill cannot reproduce), so a lamp transition takes a whole-client repaint. **When the
meter is covered or absent** (Browse, the curve popup, the empty state) `invalidateMeter`
skips invalidating anything at all rather than falling back to a whole-client repaint — the
ballistics still advance on `onMeterTimer`'s own clock, but nothing visible changed, so a
60 FPS whole-client repaint under a modal sheet would be pure cost for zero pixels shown.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+5 -6
View File
@@ -51,11 +51,9 @@ void ReaSamplerEditor::paint(HDC hdc, const RECT& dirty) {
// A meter frame's dirty rect lies wholly inside the bar field, and nothing else is drawn
// there — so the field is all that needs redrawing, and the rest of the face is still in the
// back buffer from the last full compose. Decided GEOMETRICALLY rather than by a flag,
// because Windows coalesces a meter invalidate with any other pending one into a single
// union rect: a flag would then paint a meter frame over a face that had really changed.
const Rect& mf = meterRects_.field;
if (!mf.empty() && dx >= mf.x && dy >= mf.y && dr <= mf.right() && db <= mf.bottom()) {
// back buffer from the last full compose. Decided GEOMETRICALLY rather than by a flag — see
// meterFastPathEligible (master_meter) for why.
if (meterFastPathEligible(meterRects_, Rect::ltrb(dx, dy, dr, db))) {
paintMeterField(bmp);
} else {
// Repopulated by paintDeck when the meter is on screen; left empty by the empty state.
@@ -83,7 +81,8 @@ void ReaSamplerEditor::paint(HDC hdc, const RECT& dirty) {
}
// A sheet layered over the face covers the meter, so the fast path must not paint
// through it — dropping the rect routes the next meter tick down this path instead.
// through it even if something else invalidates a rect that happens to match the
// stale field bounds — invalidateMeter itself already skips while covered (see there).
if (view_ == View::kBrowse || curvePopup_ != CurveTarget::kNone) meterRects_ = {};
}
@@ -54,6 +54,10 @@ void drawMeterNumerals(LICE_IBitmap* bmp, const MeterRects& m) {
// covers every pixel the rest of it then draws, so a meter frame can redraw this rect alone.
// Two bars or one is read off the rects (barB is empty exactly when the split is Single), so
// the lane decision has one representation here rather than two.
// This self-containment depends on Role::BgCell being fully OPAQUE at InteractionState::Rest
// (theme.cpp: alpha 255) — fillGradient blends rather than overwrites at alpha < 255, so a
// translucent BgCell would make every meter frame re-blend over whatever the last frame left,
// the exact AA-thickening the numeral gutter comment below is guarding against.
void drawMeterField(LICE_IBitmap* bmp, const MeterRects& m, const MasterMeterUi& state) {
// A column narrower than the interior needs yields all-empty rects, which under rect.h's
// contract means suppressed — not a zero-height field to fill, tick twelve times and cap.
+17 -1
View File
@@ -77,8 +77,20 @@ void ReaSamplerEditor::invalidate() {
void ReaSamplerEditor::invalidateMeter() {
if (!childHwnd_) return;
// The meter is covered (Browse, the curve popup) or has nothing to draw (empty state) —
// ballistics still advance in onMeterTimer, but nothing on screen changed, so invalidating
// anything here would only buy a whole-client repaint of chrome/waveform/deck the meter
// never touches. Distinct from "bounds not resolved yet" below: this is a fact about what
// the face is showing, not about whether meterRects_ happens to be populated.
const bool meterOnScreen =
view_ == View::kSample && curvePopup_ == CurveTarget::kNone && !selectedId_.empty();
if (!meterOnScreen) return;
const Rect& f = meterRects_.field;
if (f.empty()) { // nothing cached (see meterRects_) — the whole face has to answer
if (f.empty()) {
// On screen, but no full paint has resolved its bounds yet (first paint, or a resize
// just dropped the cache) — the whole-client fallback is cheap here because the window
// is already fully invalid from the resize/creation that caused this.
invalidate();
return;
}
@@ -155,6 +167,10 @@ void ReaSamplerEditor::removedFromParent() {
childHwnd_ = nullptr;
}
releaseBackBuffer(); // a client-area bitmap outlives nothing here
// Unreachable today (WM_PAINT outranks WM_TIMER, so a reopen's first paint resolves this
// before any meter tick can read it stale) — cleared anyway so the invariant is structural,
// not a timing accident, the same reason onSize clears it on resize.
meterRects_ = {};
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
+12 -12
View File
@@ -1,9 +1,8 @@
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
// pure modules; every edit commits off the audio thread via reloadInstrument.
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules. The
// Sample face is a three-band stack — chrome, waveform, decks — and the shell TUs split on
// that same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in
// the pure modules; every edit commits off the audio thread via reloadInstrument.
#pragma once
@@ -65,6 +64,9 @@ public:
// always supplies one).
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
~ReaSamplerEditor() override;
// backBuffer_ owns a raw LICE bitmap with no refcount behind it — a copy would double-free.
ReaSamplerEditor(const ReaSamplerEditor&) = delete;
ReaSamplerEditor& operator=(const ReaSamplerEditor&) = delete;
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
Steinberg::FIDString type) override;
@@ -302,8 +304,8 @@ private:
struct ChannelPcm {
std::vector<AudioSample> interleaved; // frame-interleaved source frames
int channelCount = 0; // 0 = nothing decoded
Envelope heroEnv; // binned from `interleaved`, so every path that drops the
std::size_t heroBins = 0; // decode drops it too. 0 = not computed
Envelope heroEnv; // binned from `interleaved`; cleared whenever it is
std::size_t heroBins = 0; // 0 = not computed
std::int64_t frameCount() const {
return channelCount > 0
? static_cast<std::int64_t>(interleaved.size()) / channelCount
@@ -467,9 +469,7 @@ private:
// up. One note at a time — a fresh press releases the prior.
int previewingNote_ = -1;
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
// would nest a synchronous REAPER action — which re-points this very instance — inside
// a mouse handler with the capture held.
// Resample bake — the click only ARMS it (see this directory's CLAUDE.md Gotchas for why).
bool bakePending_ = false;
// Whether the extension's bake action is registered, resolved on the same tick that
// governs the button's paint, so the control is never enabled and then refusing.
@@ -494,8 +494,8 @@ private:
// The MASTER deck's meter, advanced from the published block magnitudes on its own timer
// (see onMeterTimer for why it runs mid-drag too, and why the first read is discarded).
// A default-constructed meterTick_ means never ticked. meterRects_ is the column interior
// resolved at the last FULL paint; an empty field means "not on screen" (no layout yet, the
// empty state, a modal sheet over it) and sends the tick down the whole-client path.
// resolved at the last FULL paint; an empty field is ambiguous alone (no layout yet, or
// genuinely off screen) — invalidateMeter asks view_/curvePopup_/selectedId_ to tell which.
instrument::ui::MasterMeterUi masterMeter_;
std::chrono::steady_clock::time_point meterTick_{};
instrument::ui::MeterRects meterRects_;