修复悬停高亮变长方形色块/联动失效 + 信号列表动画卡顿真因(行位图缓存)

一、悬停高亮恢复"整条曲线发光"(上一轮裁成鼠标附近一段 ⇒ 满幅方波上就是个长方形色块)
光晕的语义是"整条曲线一起发光",对编码器这种满幅方波,一列包络本来就跨满网格高,
裁成 144px 宽 × 满高的窗口在屏幕上就是一个像选区框的色块(用户 2026-10-18 第二轮报告)。
修法(PlotViewPaint.cpp):
- PaintSignalHaloFor 恢复**整条**几何(包络带 FillPath / 折线 DrawLines);
- 新增 PaintSignalHaloCached:光晕几何只取决于「信号 + 视界 + 网格几何 + 样式」,**与鼠标无关**
  ⇒ 渲染一次进离屏位图,之后每帧只做一次 AlphaBlend(源位图预乘 alpha,用 256×256 查表);
- 缓存键与 AcquirePolyline 同源,**不含网格屏幕位置**(滚动网格只按新位置贴同一张位图)。
  210ms/帧(整条描边)→ 一次性 15ms 渲染 + 每帧 ~2ms 贴图。
halocheck 校验:悬停必命中(60/60 位置)、光晕贴合带宽(outsideBand=0)、不越出网格、
bbox 覆盖整条曲线(不是中段方块)、列表行悬停联动仍生效。

二、信号列表动画卡顿的三个真因(探针 panelstutter:129 个可见网格 + 反复"展开→切组复选框→收起")
1) 面板每帧重画 ~36 行 × ~0.35ms(十来个 GDI+ 原语)⇒ panel.Paint 24~35ms;
2) 动画帧里仍在预热网格图层:每帧重建 1 个网格图层,2500 万采样信号 10~30ms ⇒ 帧被拉长;
3) 勾选分组会把上百个**离屏**网格排进待重画队列,随后每帧补 1 个 ⇒ 数秒持续掉帧。
修法:
1) 行位图缓存(SignalPanel::PaintRowCached):按"行的可见内容"做键,整行渲染进离屏 DIB,
   命中时一次 BitBlt。panel.Paint 24~35ms → 5~6ms。
   ⚠️ 必须 DIB + 内存 DC(gd::Bitmap 上 GDI 会关闭 ClearType,整列文字退化);
   ⚠️ 行内几何在 RowRect 里平移(GDI 文本不认 GDI+ 世界变换,否则文字画到小位图之外);
   ⚠️ 渲染到行位图时不按列表矩形裁剪,裁剪放到贴图时的 DC(GDI 不认 GDI+ 裁剪,
      必须自己 SaveDC + IntersectClipRect,折起动画时行不许画到组名上);
   ⚠️ 缓存上限 192 行(≈10MB)。静态画面逐像素一致(面板区域 54/275200 不同)。
2) 新增 PlotView::SetAnimationBusy:UI 动画期间不预热网格图层(队列保留,动画结束后继续)。
3) 待重画队列只保留可视 + 预取带内的网格;RollWideBuffer 不再登记离屏网格。
实测:展开 p50 35ms → 13ms、收起 33ms → 13ms、动画帧内图层重建 56 次 → 0 次。

三、试过并放弃(避免以后重走)
- ExtTextOutW 替代 DrawTextW:快 2~4 倍但光栅化不同(面板 10% 像素差异)⇒ 观感变化,放弃;
- 用 gd::Bitmap 做行位图:文字丢失 ClearType,放弃。

四、验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/perf/panelstutter_main.cpp(8/8)、halocheck_main.cpp(16/16);
- 回归 verify 49/49、panelanim 158/158、hoverdrag 20/20、gridanim 93/93、lanescroll 23/23、
  datacheck 69/69、edgecheck 46/46、errcheck 19/19、panflash 19/19、stress 6/6;
- make release / make debug 零警告(TARGET_FILE_NAME=TargetCheck/TargetCheckD)。

文档:Documents/agent_mem.md 追加本轮小节(含 4 条新规则:高亮语义不得为性能让步、
GDI 文本的三个隐形陷阱、面板卡顿先量"每行成本×可见行数"与"本帧是否顺带做绘图区重活"、
动画帧内绝不做可延迟的整帧级工作)。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-18 21:40:56 +08:00
1 parent 0efaa8c0ca
commit 24f0bf849d
11 files changed
+853 -197

No files matched your search

@@ -806,6 +806,76 @@ namespace jdi
*/
void PaintSignalRow(gd::Graphics& g, int row);
/**
* @brief 把一行(分组行 / 信号行)**绘制进离屏位图缓存**,再整块贴到列表上。
*
* ⚠️ 一行的绘制要调用十来个 GDI+ 原语(圆角托底 / 复选框 / 色块 / 两段文字…),
* 实测 **0.35ms/行**;列表一屏能放 35~40 行 ⇒ 单帧 **12~15ms** 全花在重复绘制
* **内容完全没变**的行上(分组折起/展开动画只是在整行平移)。
* 因此按"行的可见内容"做键缓存位图:动画帧只需把命中的位图按新位置贴出来。
*
* ⚠️ 缓存期间行的绘制**不能**裁到列表矩形(`m_rowClipRender`):位图是整行内容,
* 裁剪由贴图时的 GDI+ 裁剪区负责 —— 否则部分滚出上边界的行会永久丢掉顶部像素。
*
* @param [IN,OUT] g 绘图上下文(调用方已把裁剪区设为列表矩形)。
* @param [IN] row 行下标。
*/
void PaintRowCached(gd::Graphics& g, int row);
/**
* @brief 计算一行的缓存键(涵盖影响该行像素的全部状态)。
* @param [IN] row 行下标。
* @return 键值。
*/
std::uint64_t RowCacheKey(int row) const;
/** @brief 作废全部行位图缓存(行集合 / 字体 / 主题变化时调用)。 */
void InvalidateRowCache();
/** @brief 一行的离屏位图缓存项。 */
struct RowBitmapCache
{
/**
* @brief 该行的像素(DIB 内存 DC,已铺面板底色)。
*
* ⚠️ 必须是 **DIB + 内存 DC**,不能用 `gd::Bitmap`:GDI 在 GDI+ 位图的临时 HDC 上
* 会**关闭 ClearType**(退化为灰度抗锯齿),列表文字因此变细、观感与正文不一致
*(实测:整列文字丢失亚像素渲染)。DIB 内存 DC 上 ClearType 与屏幕一致。
*/
HDC dc = nullptr;
HBITMAP dib = nullptr;
std::uint64_t key = 0; /**< 内容键(不一致即重建)。 */
int width = 0; /**< 位图宽度(= 列表宽度)。 */
int height = 0; /**< 位图高度(= 行高)。 */
};
std::vector<RowBitmapCache> m_rowBitmapCache; /**< 与 `m_rows` 同序的行位图缓存。 */
/**
* @brief 行位图缓存的**数量上限**(超过即整体作废重建)。
*
* 一行位图 ≈ 列表宽 × 行高 × 4B(430×30×4 ≈ 51KB)。用户滚动浏览上千行时,
* 缓存会按"画过的行下标"逐渐长大,必须有上限(这里 192 行 ≈ 10MB)。
*/
static constexpr std::size_t kRowBitmapCacheLimit = 192;
std::size_t m_rowBitmapLiveCount = 0; /**< 当前持有的行位图数量。 */
/** @brief 行绘制正被渲染进离屏位图(此时禁止按列表矩形裁剪)。 */
bool m_rowClipRender = false;
/**
* @brief 行位图缓存渲染期间的**几何平移量**(把整行搬到"位图坐标系")。
*
* ⚠️ 不能用 `gd::Graphics::TranslateTransform`:行内**文本走 GDI `DrawTextW`**,
* 而 GDI 文本**不遵循 GDI+ 的世界变换**(`Graphics::GetHDC` 拿到的 DC 只有裁剪区),
* 于是文字会被画到绝对坐标处、落到小位图之外(实测量产现象:行里只剩复选框/色块,
* 文字全部消失)。因此在**几何层**平移:`RowRect` 是行内一切矩形的唯一来源,
* 它统一减去该原点即可(`SignalBadgeRect` / ✕ / 名称框等全部自动跟随)。
*/
bool m_rowRenderActive = false;
float m_rowRenderOriginX = 0.0f; /**< 位图左上角(屏幕 x)。 */
float m_rowRenderOriginY = 0.0f; /**< 位图左上角(屏幕 y)。 */
/**
* @brief 判断哪个分组行需要「悬浮」在列表顶部(返回行下标,无需悬浮时返回 -1)。
*
+12 -3
View File
@@ -374,6 +374,9 @@ namespace jdi
{
m_rows.clear();
m_rowsDirty = false;
// 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。
// 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。
InvalidateRowCache();
if (m_dataSet == nullptr || m_tab == nullptr)
{
@@ -500,10 +503,15 @@ namespace jdi
// 「组名悬浮」期间把分组行钉在列表顶部绘制(见 `PaintStickyGroupRow`):
// 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标,
// 名称框 / ✕ / 徽标等位置会自动跟随。
//
// ⚠️ 行位图缓存渲染时整体平移到"位图坐标系"(见 `m_rowRenderActive` 的说明:
// GDI 文本不认 GDI+ 变换,必须在几何层平移)。
const float renderOffsetX = m_rowRenderActive ? -m_rowRenderOriginX : 0.0f;
const float renderOffsetY = m_rowRenderActive ? -m_rowRenderOriginY : 0.0f;
if (m_rowYOverrideActive)
{
return gd::RectF(m_listRect.X, m_rowYOverrideY, m_listRect.Width,
m_rows[static_cast<std::size_t>(row)].height);
return gd::RectF(m_listRect.X + renderOffsetX, m_rowYOverrideY + renderOffsetY,
m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
}
float top = m_listRect.Y - m_scrollOffset;
@@ -516,7 +524,8 @@ namespace jdi
{
top -= m_rowShift[static_cast<std::size_t>(row)];
}
return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width,
m_rows[static_cast<std::size_t>(row)].height);
}
int SignalPanel::GroupLastRowIndex(int row, int rowCount) const
+254 -11
View File
@@ -4,6 +4,8 @@
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
#include "SignalPanelInternal.hpp"
#include <cstring>
namespace jdi
{
@@ -186,14 +188,10 @@ namespace jdi
g.SetClip(m_rowClipRect, gd::CombineModeIntersect);
}
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintGroupRow(g, row);
}
else
{
PaintSignalRow(g, row);
}
// ⚠️ 整行走**位图缓存**:分组折起/展开动画只是在平移行,
// 行的像素一个也没变;逐行重画十来个 GDI+ 原语要 0.35ms/行
//(一屏 35~40 行 ⇒ 每帧 12~15ms),这是"只有文字的列表却滚动卡顿"的真因。
PaintRowCached(g, row);
g.Restore(rowSave);
ClearRowClip();
}
@@ -227,6 +225,14 @@ namespace jdi
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
// 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内,
// 否则垂直居中的文字会溢出到相邻行上。
//
// ⚠️ **行位图缓存渲染时反而不能裁剪**(`m_rowClipRender`):位图是整行内容,
// 裁剪留到贴图时由 GDI+ 裁剪区完成(否则部分滚出上边界的行会永久丢掉顶部像素)。
if (m_rowClipRender)
{
UiText(g, text, font, rect, argb, align);
return;
}
UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect);
}
@@ -235,14 +241,251 @@ namespace jdi
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
gd::RectF clipped = rect;
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
if (!m_rowClipRender)
{
return;
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
{
return;
}
}
UiFillRect(g, clipped, argb);
}
void SignalPanel::InvalidateRowCache()
{
for (RowBitmapCache& cache : m_rowBitmapCache)
{
if (cache.dc != nullptr)
{
DeleteDC(cache.dc);
cache.dc = nullptr;
}
if (cache.dib != nullptr)
{
DeleteObject(cache.dib);
cache.dib = nullptr;
}
}
m_rowBitmapCache.clear();
m_rowBitmapLiveCount = 0;
}
std::uint64_t SignalPanel::RowCacheKey(int row) const
{
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
const auto mixText = [&mix](const std::wstring& text)
{
mix(text.size());
for (const wchar_t character : text)
{
mix(static_cast<std::uint64_t>(character));
}
};
if (row < 0 || row >= static_cast<int>(m_rows.size()))
{
return hash;
}
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
mix(static_cast<std::uint64_t>(item.kind));
mix(static_cast<std::uint64_t>(item.groupIndex + 1));
mix(static_cast<std::uint64_t>(item.signalIndex + 1));
mixFloat(item.height);
mixFloat(m_scale);
mix(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(m_theme)));
mix(static_cast<std::uint64_t>(reinterpret_cast<std::uintptr_t>(m_fonts)));
mix(static_cast<std::uint64_t>((m_hoverRow == row) ? (static_cast<int>(m_hoverPart) + 1) : 0));
if (item.kind == RowKind::GROUP)
{
const SignalGroup* group =
(m_dataSet != nullptr) ? m_dataSet->GroupAt(item.groupIndex) : nullptr;
mixText((group != nullptr) ? group->name : std::wstring());
mixText(GroupStatsText(row));
mix(IsGroupCollapsed(item.groupIndex) ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(item.displayCount + 1));
if (m_tab != nullptr)
{
mix(m_tab->GroupAllVisible(item.groupIndex) ? 1ull : 0ull);
mix(m_tab->GroupHasVisible(item.groupIndex) ? 2ull : 0ull);
}
const bool editing = (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row);
mix(editing ? 1ull : 0ull);
if (editing)
{
mixText(m_edit.Text());
}
return hash;
}
const SignalRef ref{item.groupIndex, item.signalIndex};
const SignalData* signal = (m_dataSet != nullptr) ? m_dataSet->Resolve(ref) : nullptr;
const SignalStyle* style = StyleOf(ref);
mixText((signal != nullptr) ? signal->name : std::wstring());
mix((SelectedRef() == ref) ? 1ull : 0ull);
if (style != nullptr)
{
mix(static_cast<std::uint64_t>(style->color));
mixFloat(style->opacity);
}
if (m_tab != nullptr)
{
mix(m_tab->IsVisible(ref) ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(m_tab->LaneOfSignal(ref) + 1));
}
const bool editing = (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row);
mix(editing ? 1ull : 0ull);
if (editing)
{
mixText(m_edit.Text());
}
return hash;
}
void SignalPanel::PaintRowCached(gd::Graphics& g, int row)
{
if (m_dataSet == nullptr || m_tab == nullptr || m_theme == nullptr || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return;
}
const gd::RectF rect = RowRect(row);
if (rect.Height <= 0.5f)
{
return;
}
const int originX = static_cast<int>(std::floor(m_listRect.X));
const int originY = static_cast<int>(std::floor(rect.Y));
const int width = static_cast<int>(std::ceil(m_listRect.Width));
const int height = static_cast<int>(std::ceil(rect.Height));
if (width <= 0 || height <= 0)
{
return;
}
// ⚠️ 上限检查必须在**取引用之前**:`InvalidateRowCache()` 会清空容器,
// 清理后原有引用(`RowBitmapCache&`)就悬空了。
if (m_rowBitmapLiveCount >= kRowBitmapCacheLimit)
{
InvalidateRowCache();
}
if (m_rowBitmapCache.size() < m_rows.size())
{
m_rowBitmapCache.resize(m_rows.size());
}
RowBitmapCache& cache = m_rowBitmapCache[static_cast<std::size_t>(row)];
const std::uint64_t key = RowCacheKey(row);
const bool sameSize = (cache.dc != nullptr) && cache.width == width && cache.height == height;
if (!sameSize || cache.key != key)
{
if (!sameSize)
{
if (cache.dc != nullptr)
{
DeleteDC(cache.dc);
cache.dc = nullptr;
if (m_rowBitmapLiveCount > 0)
{
--m_rowBitmapLiveCount;
}
}
if (cache.dib != nullptr)
{
DeleteObject(cache.dib);
cache.dib = nullptr;
}
// ⚠️ DIB + 内存 DC(而不是 `gd::Bitmap`):GDI 在 GDI+ 位图的临时 HDC 上
// 会关闭 ClearType,导致列表文字丢失亚像素渲染、与正文观感不一致。
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;
HDC memoryDc = CreateCompatibleDC(nullptr);
HBITMAP dib = (memoryDc != nullptr)
? CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0)
: nullptr;
if (memoryDc == nullptr || dib == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
if (memoryDc != nullptr)
{
DeleteDC(memoryDc);
}
return;
}
SelectObject(memoryDc, dib);
cache.dc = memoryDc;
cache.dib = dib;
cache.width = width;
cache.height = height;
++m_rowBitmapLiveCount;
}
{
gd::Graphics rowGraphics(cache.dc);
rowGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias);
rowGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
rowGraphics.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
rowGraphics.Clear(ToGdiColor(m_theme->panelBg));
// ⚠️ 用**几何平移**而不是 `TranslateTransform`(见 `m_rowRenderActive`)。
m_rowRenderActive = true;
m_rowRenderOriginX = static_cast<float>(originX);
m_rowRenderOriginY = static_cast<float>(originY);
m_rowClipRender = true;
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
{
PaintGroupRow(rowGraphics, row);
}
else
{
PaintSignalRow(rowGraphics, row);
}
m_rowClipRender = false;
m_rowRenderActive = false;
}
cache.key = key;
}
// ⚠️ 整块 1:1 搬运(不经过 GDI+ 采样/混合),字的亚像素边缘才能原样保留。
// ⚠️ **必须自己给 DC 设裁剪**:`Graphics::GetHDC()` 交给我们的 DC 上,(a) GDI 绘制
// 不受 GDI+ `SetClip` 的约束(这是本文件多处注释过的坑),(b) 折起/展开动画时行会
// 滑进组名栏,必须裁到 `m_rowClipRect`。因此这里显式 `IntersectClipRect` 后再 `BitBlt`。
const HDC targetDc = g.GetHDC();
if (targetDc != nullptr)
{
const int saved = SaveDC(targetDc);
if (saved != 0)
{
const gd::RectF clip = m_rowClipActive ? m_rowClipRect : m_listRect;
IntersectClipRect(targetDc, static_cast<int>(std::floor(clip.X)),
static_cast<int>(std::floor(clip.Y)),
static_cast<int>(std::ceil(clip.X + clip.Width)),
static_cast<int>(std::ceil(clip.Y + clip.Height)));
}
BitBlt(targetDc, originX, originY, width, height, cache.dc, 0, 0, SRCCOPY);
if (saved != 0)
{
RestoreDC(targetDc, saved);
}
g.ReleaseHDC(targetDc);
}
}
void SignalPanel::PaintGroupRow(gd::Graphics& g, int row)
{
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))