fix(plotview): 修复面板条目栏悬停时预览曲线高亮丢失 + 极端工况绘制管线提速

一、悬停高亮回归修复
- 根因:高亮画在预览曲线绘制路径里,而该路径在命中静态场景缓存时完全不会被调用
  (悬停态又不参与 SceneContentHash),因此缓存命中帧永远看不到高亮。
- 修法:拆出 PaintDerivedCurvesFor(hoverOnly) 两态,新增 PaintDerivedHoverHighlight,
  在 paintOverlays() 中紧跟 PaintHoverHighlight 以前景方式绘制(面板之下、光标之上)。
  高亮仍按面板种类(均值滤波/中心轴/弱峰调整)分别判定。

二、预览曲线绘制重写(单条 15ms → 1.7ms)
- 稠密区间改用与波形基底同一套「逐像素列 min/max 包络带」+ 一次 FillPath;
  旧抽稀折线在稠密视界下每段都是竖直满幅,GDI+ 描边代价正比于覆盖像素数
  (3200 顶点满幅锯齿抗锯齿 128ms / 无抗锯齿 31ms,包络带 1.9ms)。
- 列密度自适应:中等密度 2px/列,极密(>8 采样/像素)4px/列。
- 稀疏区间保持逐点折线;附带修复「等间隔抽样漏掉尖峰」。

三、预览曲线像素缓存层(Picture cache + pan roll)
- 45 条曲线画进带 alpha 的 32bpp DIB,正常帧只做一次 AlphaBlend(1~2ms)。
- 纯平移时整行 memmove 一个 dx 并只重画新露出的竖带(与宽缓冲区滚动同构),
  因此拖动视界的每帧代价与信号数无关。
- 内容指纹 = SceneContentHash(含样式/派生参数/网格几何) ⊕ 曲线发布序号;高亮不进指纹。

四、其他热点
- 光标取值胶囊不再逐帧重算并发布 45 条曲线(CurveSpanFor 新增 computeIfMissing),
  只取已覆盖的缓存曲线,未命中退到最新曲线或本帧不标注。
- 样式补丁路径不再同步重建网格图层(宽基底 2 倍宽 × 2 倍高,实测单次 2.2 秒),
  且开启派生功能时直接走整幅重绘(补丁会覆盖缓存中已烘入的曲线像素)。

实测(15 信号合并一条网格 + 全部实验性功能,1600x900 离屏探针):
拖动视界 264→5.9ms,双光标拖动 187→20.9ms,弱峰滑块 555→89ms,
改信号颜色 2661→200ms,悬停面板行 225→38ms,深放大拖动 207→23.8ms。
全屏 1920x1080:拖动视界 9.3ms。

验证:新增离屏探针 p27 8/8(悬停高亮在缓存命中帧可见、平移/缩放/改颜色后的叠加层
与「完全重画」参考帧逐像素一致 diff=0);p20 9/9、p21 9/9、p22 14/14、p23 8/8、p25 12/12;
make release / make debug 零 warning;Release 814,080 B。规则已同步到 Documents/agent_mem.md。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-02 18:47:21 +08:00
1 parent ea704b03ea
commit 0c320cf1b3
6 files changed
+797 -33

No files matched your search

+300
View File
@@ -744,6 +744,306 @@ namespace jdi
return any;
}
// ---------------------------------------------------------------------------
// 「派生预览曲线」像素缓存
// ---------------------------------------------------------------------------
std::uint64_t PlotView::DerivedOverlayKey() const
{
// 内容指纹 = 场景内容(含外观样式 + 派生参数 + 网格幅值范围 + 网格滚动量 + 绘图区几何)。
// ⚠️ 视界**不在这里**:平移时要靠它判断“能否只滚动像素”,所以视界单独比对。
std::uint64_t hash = SceneContentHash(true, true, true, true);
// 后台算好的派生曲线到达时,曲线像素必须更新(`m_curveSerial` 随每次发布自增)。
{
std::lock_guard<std::mutex> lock(m_curveMutex);
hash ^= m_curveSerial * 1099511628211ull;
}
// 高亮状态**不参与**:高亮由前景单独绘制(见 `PaintDerivedHoverHighlight`)。
return hash;
}
bool PlotView::EnsureDerivedCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (m_derivedDc != nullptr && m_derivedBits != nullptr && m_derivedW == width &&
m_derivedH == height)
{
return true;
}
ReleaseDerivedCache();
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
// 与装饰层同理:必须是 32bpp DIB 段(带真 alpha + 像素可直接 memmove 滚动)。
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
ReleaseDC(nullptr, screen);
if (dib == nullptr || bits == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (dc == nullptr)
{
DeleteObject(dib);
return false;
}
m_derivedOldBitmap = SelectObject(dc, dib);
m_derivedDc = dc;
m_derivedBitmap = dib;
m_derivedBits = bits;
m_derivedW = width;
m_derivedH = height;
m_derivedValid = false;
return true;
}
void PlotView::ReleaseDerivedCache()
{
m_derivedValid = false;
if (m_derivedDc != nullptr)
{
if (m_derivedOldBitmap != nullptr)
{
SelectObject(m_derivedDc, m_derivedOldBitmap);
m_derivedOldBitmap = nullptr;
}
DeleteDC(m_derivedDc);
m_derivedDc = nullptr;
}
if (m_derivedBitmap != nullptr)
{
DeleteObject(m_derivedBitmap);
m_derivedBitmap = nullptr;
}
m_derivedBits = nullptr;
m_derivedW = 0;
m_derivedH = 0;
}
bool PlotView::RenderDerivedCache()
{
m_derivedValid = false;
if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty())
{
return false;
}
const int width = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int height = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
if (!EnsureDerivedCache(width, height))
{
return false;
}
const std::size_t bytes = static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4u;
if (!m_derivedStripActive)
{
// 整块重画:先把全部像素清成透明。
std::memset(m_derivedBits, 0, bytes);
}
else
{
// 补条带:只把这条竖带清成透明(其余像素是滚动过来的有效内容)。
const int x0 = std::max(0, static_cast<int>(std::floor(m_derivedStripX0)));
const int x1 = std::min(width, static_cast<int>(std::ceil(m_derivedStripX1)));
for (int y = 0; y < height; ++y)
{
unsigned char* row = static_cast<unsigned char*>(m_derivedBits) +
(static_cast<std::size_t>(y) * static_cast<std::size_t>(width)) * 4u;
if (x1 > x0)
{
std::memset(row + static_cast<std::size_t>(x0) * 4u, 0,
static_cast<std::size_t>(x1 - x0) * 4u);
}
}
}
{
gd::Graphics g(m_derivedDc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
if (m_derivedStripActive)
{
// ⚠️ 绘制期的横向裁剪(`PaintDerivedCurvesFor` 只做 `intersect`,不会覆盖这一层)。
g.SetClip(gd::RectF(m_derivedStripX0, 0.0f, std::max(0.0f, m_derivedStripX1 - m_derivedStripX0),
static_cast<float>(height)),
gd::CombineModeIntersect);
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintDerivedCurvesFor(g, static_cast<int>(index), m_lanes[index], false);
}
}
m_derivedContentKey = DerivedOverlayKey();
m_derivedViewMin = Tab().ViewMin();
m_derivedViewMax = Tab().ViewMax();
m_derivedValid = true;
return true;
}
bool PlotView::BlitDerivedCache(gd::Graphics& g)
{
if (!m_derivedValid || m_derivedDc == nullptr || m_plotRect.Width < 1.0f)
{
return false;
}
void* proc = ResolveAlphaBlend();
if (proc == nullptr)
{
return false;
}
using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION);
AlphaBlendFn blend = reinterpret_cast<AlphaBlendFn>(proc);
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
// 缓存位图原点 = 客户区 (0,0):源与目标坐标相同。只贴绘图区(曲线被夹在网格内)。
const int x = std::max(0, static_cast<int>(std::floor(m_plotRect.X)));
const int y = std::max(0, static_cast<int>(std::floor(m_plotRect.Y)));
const int w = std::min(m_derivedW - x, static_cast<int>(std::ceil(m_plotRect.Width)));
const int h = std::min(m_derivedH - y, static_cast<int>(std::ceil(m_plotRect.Height)));
if (w <= 0 || h <= 0)
{
g.ReleaseHDC(target);
return false;
}
BLENDFUNCTION fn = {};
fn.BlendOp = AC_SRC_OVER;
fn.SourceConstantAlpha = 255;
fn.AlphaFormat = AC_SRC_ALPHA;
const BOOL done = blend(target, x, y, w, h, m_derivedDc, x, y, w, h, fn);
g.ReleaseHDC(target);
return done != FALSE;
}
void PlotView::PaintDerivedOverlayLayer(gd::Graphics& g)
{
if (m_loadingBusy || m_lanes.empty())
{
return; // 加载遮罩期间不花时间在派生曲线上(本来也看不见)
}
// 没有任何派生功能开启时直接跳过(并释放缓存,避免白占内存)。
bool anyFeature = false;
for (const Lane& lane : Tab().Lanes())
{
if (lane.edgeCenter || lane.meanFilter || lane.weakPeak)
{
anyFeature = true;
break;
}
}
if (!anyFeature)
{
m_derivedValid = false;
return;
}
const std::uint64_t contentKey = DerivedOverlayKey();
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
bool needRender = true;
bool rolled = false;
m_derivedStripActive = false;
if (m_derivedValid && m_derivedContentKey == contentKey && span > 0.0)
{
const double cachedSpan = m_derivedViewMax - m_derivedViewMin;
const double sameSpanTolerance = std::max(1.0e-12, std::fabs(span) * 1.0e-9);
if (std::fabs(cachedSpan - span) <= sameSpanTolerance &&
m_derivedViewMin == viewMin && m_derivedViewMax == viewMax)
{
needRender = false; // 完全同源:只贴图
}
else if (std::fabs(cachedSpan - span) <= sameSpanTolerance && m_plotRect.Width > 1.0f)
{
// 纯平移:像素整体横移,只重画新露出的竖带(与宽缓冲区的滚动同构)。
const double pxPerTime = static_cast<double>(m_plotRect.Width) / span;
const int shift = static_cast<int>(
std::lround((m_derivedViewMin - viewMin) * pxPerTime));
const int width = m_derivedW;
if (shift != 0 && std::abs(shift) < width)
{
const std::size_t rowBytes = static_cast<std::size_t>(width) * 4u;
const std::size_t moveBytes =
rowBytes - static_cast<std::size_t>(std::abs(shift)) * 4u;
unsigned char* bits = static_cast<unsigned char*>(m_derivedBits);
if (bits != nullptr && moveBytes > 0)
{
for (int y = 0; y < m_derivedH; ++y)
{
unsigned char* row = bits + static_cast<std::size_t>(y) * rowBytes;
if (shift > 0)
{
// 内容右移:从本行右端往前搬(`memmove` 自带重叠安全)。
std::memmove(row + static_cast<std::size_t>(shift) * 4u, row, moveBytes);
}
else
{
// 内容左移:源在本行右侧 —— 目标**不能**越过行首(否则越界)。
std::memmove(row, row + static_cast<std::size_t>(-shift) * 4u, moveBytes);
}
}
}
const float plotLeft = m_plotRect.X;
const float plotRight = m_plotRect.X + m_plotRect.Width;
if (shift > 0)
{
m_derivedStripX0 = plotLeft;
m_derivedStripX1 = std::min(plotRight, plotLeft + static_cast<float>(shift) + 1.0f);
}
else
{
m_derivedStripX0 = std::max(plotLeft, plotRight + static_cast<float>(shift) - 1.0f);
m_derivedStripX1 = plotRight;
}
if (m_derivedStripX1 > m_derivedStripX0 + 0.5f)
{
m_derivedStripActive = true;
rolled = true;
}
}
needRender = true;
}
}
if (needRender)
{
if (!RenderDerivedCache())
{
m_derivedStripActive = false;
return;
}
}
m_derivedStripActive = false;
if (!BlitDerivedCache(g))
{
// 无法 AlphaBlend:退回逐条曲线直接绘制(正确性优先,代价高但极少发生)。
if (!rolled)
{
m_derivedValid = false;
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLaneDerivedOverlays(g, static_cast<int>(index), m_lanes[index]);
}
}
}
bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height)
{
if (width <= 0 || height <= 0)