装饰层改用带 alpha 的独立图层(消除 GX/图例"直角白边")+ 图例圆角托底 + 预览曲线内存释放

一、GX 编号 / 图例「圆角矩形外多出一圈直角白边」的根因与修复
- 根因:装饰层原本是"从后备缓冲区**抓取一块不透明矩形**"(CaptureDecorCache + BitBlt),
  圆角之外(属于波形)的像素被一起烘进缓存;为了让这块矩形像素能跨视界复用,
  又在圆角矩形外铺了一层**直角**的不透明底色(OpaqueBaseRect)。两者叠加 ⇒ 圆角之外
  显示的是网格底色而不是波形 ⇒ 在密集波形上就是一圈直角白边(Y 轴刻度胶囊同理)。
- 修法:把装饰层改成**带真 alpha 的独立图层**:
  * m_decorDc 改为 32bpp CreateDIBSection(自上而下),像素可直接读写 alpha;
  * 新增 RenderDecorCache():把装饰**单独绘制**进一张全透明的位图(不再从后备缓冲区抓取),
    圆角之外保持全透明;位图覆盖范围为「客户区原点 → 绘图区右下角」,绘制坐标与正常绘制
    完全一致(GDI 文本不认 GDI+ 变换,绝不能做坐标平移);
  * BlitDecorCache() 改用 AlphaBlend(AC_SRC_ALPHA,GDI+ 写入 DIB 段即预乘 alpha)逐块合成
    ⇒ 圆角之外透出的是**当前**波形,既没有白边也不会残留旧波形像素;
  * AlphaBlend 经 GetProcAddress(gdi32!GdiAlphaBlend)(回退 msimg32!AlphaBlend)解析,
    不新增链接依赖(两台协作设备工具链不同,避免改 Makefile);
  * alpha 修补:GDI 文本(DrawTextW/ExtTextOutW)会把写入像素的 alpha 清成 0,
    若不修补文字处会变成"透明洞",因此在装饰矩形内把「A==0 且有颜色」的像素还原为不透明;
  * 删除全部外扩的直角不透明底色:Y 轴胶囊与图例托底都只画圆角形状本身;
  * 补齐「+N」(图例空间不足)与「(无可见信号)」提示的装饰矩形(否则画进缓存却不被贴回)。
- 装饰缓存的每帧开销因此更低:BlitDecorCache 约 0.19ms/帧(原 BitBlt 方案约 0.3ms),
  RenderDecorCache 仅在装饰内容变化时执行(约 2.4ms / 5 网格)。

二、图例样式:与 GX 编号徽标同款圆角托底 + 悬停/选中配色统一
- 每个图例条目 = 一个圆角矩形托底(kLegendPlateRadius=4,cardBg 底 + border 描边),
  内容为色块 + 信号名 —— "近似于一个 GX 标签,只是内容换成了图例信息"。
- 选中态与悬停态同配色(accentSoft 底 + accent 描边 + textStrong 文字),与 GX 徽标悬停一致;
  悬停高亮画在贴图之后,并**在不透明托底上原样重绘色块与文字**(否则内容会被盖住),
  同时保留对应信号的曲线光晕;LegendHit 新增 radius 字段。
- 选中态与左侧信号列表同源(DataSet::Selected()):面板选中 → 图例高亮;图例点选 → 面板行选中。

三、预览曲线内存:thread_local 工作缓冲封顶 + 关闭功能即释放
- 根因:DataModel 里三处 thread_local 工作缓冲(弱峰调整的 current/next/极值表、
  ApplyWeakPeakStage 的位移表、迭代均值滤波的中转表)。整段信号(2580 万采样)计算时
  每条后台线程常驻数百 MB,而后台曲线线程池最多 4 条线程 ⇒ 2GB 以上;thread_local
  只在线程退出时才析构 ⇒ 关掉预览不释放,只有 PauseDerivedAsync()(关闭文件分组会调用)
  join 线程后才归还(用户实测 1.42GB → 4.21GB 且关不掉)。
- 修法:① 保留容量封顶(kReuseBufferMaxElements=4M + TrimReuseBuffer + ReuseBufferScope
  作用域守卫,所有退出路径都会裁剪):超大计算改为一次性分配、用完即释放,内存不再随
  线程数放大;中/小规模仍保留容量以维持"多阶弱峰调整 991ms → 577ms"的收益。
  ② 新增 AnyDerivedPreviewEnabled() / TrimDerivedCurveDataIfIdle(),在
  SetLaneWeakPeak / SetLaneMeanFilter / SetLaneEdgeCenterCount(..., false) 里调用 ——
  关闭最后一个预览功能时立即 PauseDerivedAsync()(join 线程 ⇒ thread_local 析构 ⇒ 归还内存)
  并清空均值/弱峰曲线缓存。
- 实测(.preflight/perf/stylecheck mode 2,VERY_BIG_DATA):
  baseline 1202MB → 弱峰(10 迭代,1 条) 1399MB → 5 条曲线 1792MB → **关闭面板后 1203MB**。
  ⚠️ 计算期间的瞬时峰值仍可达约 2.2GB(整段 O(n) 计算的固有代价),但计算结束立刻归还。

四、验证(全部离屏,不占用鼠标/键盘/屏幕)
- .preflight/perf/verify_main.cpp(离屏 Application harness)**49/49 通过**,新增/强化:
  装饰位图圆角外 alpha≈0、中心 alpha=255(直接读 m_decorBits,证明没有直角白边);
  选中图例=悬停配色(accentSoft 像素 未选中 0 / 选中 1525);平移(宽基底)帧的装饰与整幅重绘
  逐像素一致(只比较装饰不透明像素:worst=1 / 29554px);图例溢出的「+N」提示已登记且可见。
- .preflight/perf/stylecheck_main.cpp:mode 0(装饰重建/贴回耗时)、mode 1(密集波形下导出
  GX+图例角落 PNG 目视)、mode 2(预览曲线内存增长/释放)、mode 4(装饰位图 alpha 形状检查)。
- zoomcheck mode 13:收敛帧 vs 整幅重绘 **逐像素 0 差异**(此前为 3503)。
- make clean && make all 零警告。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-14 03:25:10 +08:00
1 parent 7c32eca004
commit 1b3ab4ac09
4 files changed
+461 -153

No files matched your search

+51 -24
View File
@@ -403,6 +403,24 @@ namespace jdi
*/ */
void PauseDerivedAsync(); void PauseDerivedAsync();
/**
* @brief 当前标签页是否还有任何「带预览曲线」的实验性功能处于开启状态。
* @return 至少一个网格开启了弱峰调整 / 均值滤波 / 边沿计数(中心轴)返回 true。
*/
bool AnyDerivedPreviewEnabled() const;
/**
* @brief 若已无任何预览曲线功能,立即释放派生曲线的全部数据。
*
* 释放内容包括:后台线程池(**join 之后各线程的 `thread_local` 工作缓冲才析构、
* 内存才归还**)、待算队列、已算好的曲线条目与均值/弱峰曲线缓存。
*
* ⚠️ 这是"关掉预览后内存不下降"的修复:一条整段(千万级采样)曲线的中间缓冲
* 可达数百 MB,而 `thread_local` 只在线程退出时释放 —— 过去只有"
* 关闭整个文件分组"(会 `PauseDerivedAsync`)才会真正还内存。
*/
void TrimDerivedCurveDataIfIdle();
/** /**
* @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。 * @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。
* @return 状态发生变化返回 true。 * @return 状态发生变化返回 true。
@@ -1878,6 +1896,7 @@ namespace jdi
{ {
SignalRef ref; /**< 对应信号。 */ SignalRef ref; /**< 对应信号。 */
gd::RectF rect; /**< 条目托底矩形(同时是命中区域)。 */ gd::RectF rect; /**< 条目托底矩形(同时是命中区域)。 */
float radius = 0.0f; /**< 托底圆角半径(悬停高亮重绘时使用)。 */
gd::RectF swatch; /**< 色块矩形。 */ gd::RectF swatch; /**< 色块矩形。 */
gd::RectF text; /**< 文本矩形。 */ gd::RectF text; /**< 文本矩形。 */
std::wstring label; /**< 已经过省略处理的显示文本。 */ std::wstring label; /**< 已经过省略处理的显示文本。 */
@@ -2295,18 +2314,21 @@ namespace jdi
*/ */
bool m_sceneDegraded = false; bool m_sceneDegraded = false;
// ---- 「装饰层」像素缓存(Y 轴刻度胶囊 + 图例)---- // ---- 「装饰层」像素缓存(Y 轴刻度胶囊 + GX 编号 + 图例)----
// 装饰(Y 轴刻度文字与其圆角底板、图例)只取决于网格布局 / 幅值范围 / 信号外观与 // 装饰只取决于网格布局 / 幅值范围 / 信号外观与图例文案,与时间视界无关;但它
// 图例文案,与时间视界无关;但它**不适合**放进「宽基底」:平移是从基底里截取子矩形, // **不适合**放进「宽基底」:平移是从基底里截取子矩形,烘进基底的装饰会跟着一起
// 烘进基底的装饰会跟着一起移动 → 在网格左缘之外留下一排旧图例/刻度(重影)。 // 移动 → 在网格左缘之外留下一排旧图例/刻度(重影)。因此单独缓存一层的像素。
// 因此单独缓存一帧**按当前视界绘制**的装饰像素,并在内容指纹完全一致时整块贴回。
// //
// ⚠️ 关键不变量:**每一帧“真正绘制了装饰”都要重新捕获**(无论是否快速帧)。 // ⚠️ **本缓存带真 Alpha**(32bpp DIB + `AlphaBlend`),而不是"从后备缓冲区抓一块不透明
// 只要保证“缓存像素”与“缓存指纹”永远同源,贴回就不可能过期; // 矩形":装饰是**圆角**图形,抓矩形会把圆角之外(属于波形)的像素也一并烘进缓存,
// 反之(例如只在全质量帧捕获)会在几何/外观变化后贴回旧像素, // 平移贴回时露出"圆角矩形外围多出一圈直角白边 / 旧波形像素残留"(用户报告)。
// 表现为残留 / 黑块 / 错位等错误元素。 // 现在改为:**把装饰单独绘制进一张透明 DIB**(圆角之外保持全透明),贴回时用
// `AlphaBlend` 合成 ⇒ 圆角之外透出的是**当前**波形,既干净又可跨视界复用。
//
// ⚠️ 关键不变量:**每一帧要显示装饰时,都用 `m_decorKey == SceneDecorHash()` 判定缓存
// 是否同源**;不同源就必须先 `RenderDecorCache()` 再贴回。
/** /**
* @brief 确保装饰缓存位图尺寸正确(不足则重建)。 * @brief 确保装饰缓存位图尺寸正确(不足则重建为 32bpp 的 DIB 段)。
* @param [IN] width 位图宽度。 @param [IN] height 位图高度。 * @param [IN] width 位图宽度。 @param [IN] height 位图高度。
* @return 可用返回 true。 * @return 可用返回 true。
*/ */
@@ -2314,42 +2336,47 @@ namespace jdi
/** @brief 释放装饰缓存位图并作废缓存。 */ /** @brief 释放装饰缓存位图并作废缓存。 */
void ReleaseDecorCache(); void ReleaseDecorCache();
/** /**
* @brief 把本帧记录下来的装饰矩形从后备缓冲区捕获进装饰缓存。 * @brief 把装饰(Y 轴胶囊 + GX 编号 + 图例)**单独绘制**进透明的装饰缓存位图。
* @param [IN,OUT] g 绘图上下文。 @return 捕获成功返回 true。 *
* 位图覆盖范围 = 客户区原点到绘图区右下角(`m_area` 的右下角),因此绘制坐标与
* 正常绘制**完全一致**(GDI 文本不认 GDI+ 变换,必须避免任何坐标平移)。
* 结束后做一次 alpha 修补:GDI 文本会把写入像素的 alpha 清成 0,需要还原为不透明。
* @return 成功返回 true。
*/ */
bool CaptureDecorCache(gd::Graphics& g); bool RenderDecorCache();
/** /**
* @brief 把装饰缓存里的装饰贴回后备缓冲区(本帧跳过了装饰绘制时使用)。 * @brief 把装饰缓存里的装饰按 `AlphaBlend` 贴回后备缓冲区。
* @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。 * @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。
*/ */
bool BlitDecorCache(gd::Graphics& g); bool BlitDecorCache(gd::Graphics& g);
/**
* @brief 取 `AlphaBlend` 函数指针(`gdi32!GdiAlphaBlend`,必要时回退到 `msimg32!AlphaBlend`)。
*
* 走 `GetProcAddress` 而不是链接库:本项目的 Makefile 只链接 gdi32 等固定几个库,
* 且两台协作设备的工具链不同,动态解析可避免改动构建脚本、也避免引入新的链接依赖。
* @return 可用返回函数指针,否则返回 nullptr。
*/
static void* ResolveAlphaBlend();
mutable HDC m_decorDc = nullptr; /**< 装饰缓存设备上下文。 */ mutable HDC m_decorDc = nullptr; /**< 装饰缓存设备上下文。 */
mutable HBITMAP m_decorBitmap = nullptr; /**< 装饰缓存位图。 */ mutable HBITMAP m_decorBitmap = nullptr; /**< 装饰缓存位图。 */
mutable HGDIOBJ m_decorOldBitmap = nullptr; /**< 装饰 DC 原选位图。 */ mutable HGDIOBJ m_decorOldBitmap = nullptr; /**< 装饰 DC 原选位图。 */
mutable void* m_decorBits = nullptr; /**< 位图像素(32bpp DIB 段,可写 alpha)。 */
mutable int m_decorW = 0; /**< 装饰缓存宽度。 */ mutable int m_decorW = 0; /**< 装饰缓存宽度。 */
mutable int m_decorH = 0; /**< 装饰缓存高度。 */ mutable int m_decorH = 0; /**< 装饰缓存高度。 */
mutable bool m_decorValid = false; /**< 装饰缓存内容是否有效。 */ mutable bool m_decorValid = false; /**< 装饰缓存内容是否有效。 */
mutable std::uint64_t m_decorKey = 0; /**< 装饰缓存对应的内容指纹。 */ mutable std::uint64_t m_decorKey = 0; /**< 装饰缓存对应的内容指纹。 */
mutable bool m_useDecorCache = false; /**< 本帧是否跳过绘制装饰、改用缓存贴回。 */ mutable bool m_useDecorCache = false; /**< 本帧是否跳过绘制装饰、改用缓存贴回。 */
/** 本帧需要捕获的装饰矩形(各网格的 Y 轴胶囊与图例带)。 */ /** 本帧需要捕获的装饰矩形(各网格的 Y 轴胶囊 + GX 编号 + 图例托底)。 */
mutable std::vector<gd::RectF> m_decorRects; mutable std::vector<gd::RectF> m_decorRects;
/** /**
* @brief 缓存里那批像素对应的装饰矩形(捕获时与像素同时保存)。 * @brief 缓存里那批像素对应的装饰矩形(与像素同时生成)。
* *
* ⚠️ 贴回必须用这一份,**不能**用当前的 `m_decorRects`: * ⚠️ 贴回必须用这一份,**不能**用当前的 `m_decorRects`:
* 命中缓存时各网格会跳过绘制、根本不会重建矩形列表(为空), * 命中缓存时各网格会跳过绘制、根本不会重建矩形列表(为空),
* 若用当前列表就会贴错位置或一个都不贴(表现为装饰消失 / 黑块 / 错位)。 * 若用当前列表就会贴错位置或一个都不贴(表现为装饰消失 / 黑块 / 错位)。
*/ */
mutable std::vector<gd::RectF> m_decorCapturedRects; mutable std::vector<gd::RectF> m_decorCapturedRects;
/**
* @brief 捕获缓存像素时使用的绘图区原点(`m_area` 左上角)。
*
* ⚠️ 贴回时**必须用这一份**推算源偏移:宽缓冲区渲染会临时改写 `m_area`,
* 若捕获与贴回两帧的 `m_area` 不同,直接用当前 `m_area` 作原点会让整块装饰错位。
*/
mutable int m_decorOriginX = 0;
mutable int m_decorOriginY = 0;
// ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ---- // ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ----
// //
+254 -129
View File
@@ -1328,6 +1328,35 @@ namespace jdi
m_curveSceneDirty.store(false, std::memory_order_relaxed); m_curveSceneDirty.store(false, std::memory_order_relaxed);
} }
bool PlotView::AnyDerivedPreviewEnabled() const
{
if (m_tab == nullptr)
{
return false;
}
for (const Lane& lane : m_tab->Lanes())
{
if (lane.weakPeak || lane.meanFilter || lane.edgeCenter)
{
return true;
}
}
return false;
}
void PlotView::TrimDerivedCurveDataIfIdle()
{
if (AnyDerivedPreviewEnabled())
{
return; // 还有网格在用预览曲线:缓存仍然有用,不能清
}
// 停掉后台线程池:这会让各线程的 `thread_local` 工作缓冲**析构并归还内存** ——
// 这是"关掉预览后内存不下降"的根因(thread_local 只在线程退出时释放)。
PauseDerivedAsync();
m_meanCurveCache.clear();
m_weakPeakCurveCache.clear();
}
bool PlotView::FitTimeToLane(int laneIndex) bool PlotView::FitTimeToLane(int laneIndex)
{ {
if (m_dataSet == nullptr || m_tab == nullptr) if (m_dataSet == nullptr || m_tab == nullptr)
@@ -1896,6 +1925,12 @@ namespace jdi
{ {
lane.edgeCenterPanelOpen = false; lane.edgeCenterPanelOpen = false;
} }
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
if (!enabled)
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。 // 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
RebuildWideBuffer(); RebuildWideBuffer();
return true; return true;
@@ -1984,6 +2019,12 @@ namespace jdi
{ {
lane.weakPeakPanelOpen = false; lane.weakPeakPanelOpen = false;
} }
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
if (!enabled)
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」(不含派生曲线),使用户首次拖动该滑块时无需从零重建(避免一次性尖峰)。 // 预建「宽缓冲基底」(不含派生曲线),使用户首次拖动该滑块时无需从零重建(避免一次性尖峰)。
RebuildWideBuffer(); RebuildWideBuffer();
return true; return true;
@@ -2067,6 +2108,12 @@ namespace jdi
{ {
lane.meanFilterPanelOpen = false; lane.meanFilterPanelOpen = false;
} }
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
if (!enabled)
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。 // 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
RebuildWideBuffer(); RebuildWideBuffer();
return true; return true;
@@ -6053,28 +6100,31 @@ namespace jdi
{ {
PaintLaneDerivedOverlays(g, static_cast<int>(i), m_lanes[i]); PaintLaneDerivedOverlays(g, static_cast<int>(i), m_lanes[i]);
} }
// 装饰(Y 轴刻度 + 图例):基底不含(否则平移会重影)。 // 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。
// 内容指纹一致 → 直接整块贴回缓存(省掉占整幅渲染一半以上的文字绘制); // 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制);
// 否则用 `m_renderDecorOnly` 重画一遍并**立即重新捕获**。 // 否则先把装饰单独绘制进透明缓存位图,再贴回。
// ⚠️ 贴回的是**带 alpha 的圆角图形**:圆角之外是全透明,
// 因此透出的是**当前**波形 —— 既没有"直角白边",也不会残留旧波形像素。
m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash()); m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash());
if (!m_useDecorCache && RenderDecorCache())
{
m_decorKey = SceneDecorHash();
m_useDecorCache = true;
}
if (m_useDecorCache) if (m_useDecorCache)
{ {
// 贴回用**捕获时**的矩形(命中缓存时各网格不会重建矩形列表)。
BlitDecorCache(g); BlitDecorCache(g);
} }
else else
{ {
m_decorRects.clear(); // 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend):
// 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。
m_renderDecorOnly = true; m_renderDecorOnly = true;
for (std::size_t i = 0; i < m_lanes.size(); ++i) for (std::size_t i = 0; i < m_lanes.size(); ++i)
{ {
PaintLane(g, static_cast<int>(i), m_lanes[i], wideTimeTicks); PaintLane(g, static_cast<int>(i), m_lanes[i], wideTimeTicks);
} }
m_renderDecorOnly = false; m_renderDecorOnly = false;
if (CaptureDecorCache(g))
{
m_decorKey = SceneDecorHash();
}
} }
paintOverlays(); paintOverlays();
if (morePending) if (morePending)
@@ -6127,10 +6177,10 @@ namespace jdi
// 图例命中矩形每帧重建,避免残留已消失的信号。 // 图例命中矩形每帧重建,避免残留已消失的信号。
m_legendHits.clear(); m_legendHits.clear();
// 「装饰层」缓存决策:快速帧(拖拽/平移)期间、且装饰内容与上次捕获完全一致时, // 「装饰层」:装饰**不在网格循环里就地绘制**,而是统一由带 alpha 的装饰层贴回
// 跳过 Y 轴胶囊与图例的绘制(约占整幅重绘的一半),改用上次捕获的像素贴回。 // (这样圆角之外透出的是当前波形,不会出现"直角白边")。
m_decorRects.clear(); m_decorRects.clear();
m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash()); m_useDecorCache = true;
for (std::size_t index = 0; index < m_lanes.size(); ++index) for (std::size_t index = 0; index < m_lanes.size(); ++index)
{ {
@@ -6139,16 +6189,15 @@ namespace jdi
PaintTimeAxis(g, timeTicks, timeStep); PaintTimeAxis(g, timeTicks, timeStep);
// 装饰层:命中缓存则上一轮网格循环已经跳过绘制,这里整块贴回; // 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。
// 否则本轮已经真正绘制了装饰,立即捕获(保证像素与指纹同源)。 if (!m_decorValid || m_decorKey != SceneDecorHash())
if (m_useDecorCache)
{ {
BlitDecorCache(g); if (RenderDecorCache())
} {
else if (CaptureDecorCache(g)) m_decorKey = SceneDecorHash();
{ }
m_decorKey = SceneDecorHash();
} }
BlitDecorCache(g);
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
@@ -6274,11 +6323,8 @@ namespace jdi
if (badge.Width > 0.0f) if (badge.Width > 0.0f)
{ {
const float radius = kLaneBadgeHeight * m_scale * 0.5f; const float radius = kLaneBadgeHeight * m_scale * 0.5f;
// 不透明底色铺满外接矩形(与缓存里的画法一致,避免圆角之外残留旧像素)。 // 只重画**圆角形状本身**(不透明):圆角之外保持"贴图层"的像素,
const gd::RectF base(std::floor(badge.X) - 1.0f, std::floor(badge.Y) - 1.0f, // 因此不会出现直角白边。
std::ceil(badge.X + badge.Width) - std::floor(badge.X) + 2.0f,
std::ceil(badge.Y + badge.Height) - std::floor(badge.Y) + 2.0f);
UiFillRect(g, base, m_theme->cardBg | 0xFF000000u);
UiFillRoundRect(g, badge, radius, m_theme->accentSoft); UiFillRoundRect(g, badge, radius, m_theme->accentSoft);
UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f); UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f);
UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge, UiText(g, L"G" + std::to_wstring(m_hoverBadge + 1), m_fonts->monoSmall, badge,
@@ -6320,17 +6366,18 @@ namespace jdi
} }
} }
// 条目托底高亮:**不透明**圆角矩形(避免半透明叠加出杂色),并在其上**原样重绘 // 条目悬停高亮:**圆角托底 + 与选中态相同的高亮**(用户明确要求"悬停效果与 GX 标签
// 色块与文字** —— 否则不透明托底会把图例内容整块盖住(只剩一个空色块)。 // 相同、选中状态与悬停提示效果相同"),并在其上**原样重绘色块与文字** ——
// 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。
for (const LegendHit& hit : m_legendHits) for (const LegendHit& hit : m_legendHits)
{ {
if (hit.ref == m_hoverLegend) if (hit.ref == m_hoverLegend)
{ {
const float radius = kLegendPlateRadius * m_scale; const float radius = (hit.radius > 0.0f) ? hit.radius : kLegendPlateRadius * m_scale;
UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft); UiFillRoundRect(g, hit.rect, radius, m_theme->accentSoft);
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f); UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale); UiDrawSwatch(g, hit.swatch, hit.swatchColor, hit.swatchOpacity, 1.5f * m_scale, m_scale);
UiText(g, hit.label, m_fonts->uiSmall, hit.text, hit.textColor, TextAlign::LEFT); UiText(g, hit.label, m_fonts->uiSmall, hit.text, m_theme->textStrong, TextAlign::LEFT);
break; break;
} }
} }
@@ -6771,6 +6818,11 @@ namespace jdi
std::vector<double> timeTicks; std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks); BuildTicks(viewMin, viewMax, timeStep, timeTicks);
// ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格
// (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖,
// 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。
const bool savedUseDecorCache = m_useDecorCache;
m_useDecorCache = false;
for (const int laneIndex : repaint) for (const int laneIndex : repaint)
{ {
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size())) if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
@@ -6778,6 +6830,7 @@ namespace jdi
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks); PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
} }
} }
m_useDecorCache = savedUseDecorCache;
g.Restore(saveState); g.Restore(saveState);
} }
@@ -7176,7 +7229,7 @@ namespace jdi
{ {
return false; return false;
} }
if (m_decorDc != nullptr && m_decorW == width && m_decorH == height) if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height)
{ {
return true; return true;
} }
@@ -7186,15 +7239,37 @@ namespace jdi
{ {
return false; return false;
} }
m_decorDc = CreateCompatibleDC(screen); // ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`):
m_decorBitmap = CreateCompatibleBitmap(screen, width, height); // 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上;
// 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。
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); ReleaseDC(nullptr, screen);
if (m_decorDc == nullptr || m_decorBitmap == nullptr) if (dib == nullptr || bits == nullptr)
{ {
ReleaseDecorCache(); if (dib != nullptr)
{
DeleteObject(dib);
}
return false; return false;
} }
m_decorOldBitmap = SelectObject(m_decorDc, m_decorBitmap); HDC dc = CreateCompatibleDC(nullptr);
if (dc == nullptr)
{
DeleteObject(dib);
return false;
}
m_decorOldBitmap = SelectObject(dc, dib);
m_decorDc = dc;
m_decorBitmap = dib;
m_decorBits = bits;
m_decorW = width; m_decorW = width;
m_decorH = height; m_decorH = height;
return true; return true;
@@ -7219,57 +7294,103 @@ namespace jdi
DeleteObject(m_decorBitmap); DeleteObject(m_decorBitmap);
m_decorBitmap = nullptr; m_decorBitmap = nullptr;
} }
m_decorBits = nullptr;
m_decorW = 0; m_decorW = 0;
m_decorH = 0; m_decorH = 0;
} }
bool PlotView::CaptureDecorCache(gd::Graphics& g) void* PlotView::ResolveAlphaBlend()
{ {
if (m_decorRects.empty()) // `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现;
// 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。
static void* cached = nullptr;
static bool resolved = false;
if (resolved)
{
return cached;
}
resolved = true;
HMODULE gdi = GetModuleHandleW(L"gdi32.dll");
if (gdi != nullptr)
{
cached = reinterpret_cast<void*>(GetProcAddress(gdi, "GdiAlphaBlend"));
}
if (cached == nullptr)
{
HMODULE msimg = LoadLibraryW(L"msimg32.dll");
if (msimg != nullptr)
{
cached = reinterpret_cast<void*>(GetProcAddress(msimg, "AlphaBlend"));
}
}
return cached;
}
bool PlotView::RenderDecorCache()
{
m_decorValid = false;
if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty())
{ {
m_decorValid = false;
return false; return false;
} }
// 用绘图区矩形作为缓存位图尺寸(装饰都在绘图区内)。 // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致,
const int width = static_cast<int>(std::ceil(m_area.Width)); // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。
const int height = static_cast<int>(std::ceil(m_area.Height)); 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 (!EnsureDecorCache(width, height)) if (!EnsureDecorCache(width, height))
{ {
m_decorValid = false;
return false; return false;
} }
HDC source = g.GetHDC(); // 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。
if (source == nullptr) std::memset(m_decorBits, 0, static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4u);
m_decorRects.clear();
{ {
m_decorValid = false; gd::Graphics g(m_decorDc);
return false; g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
const bool savedUse = m_useDecorCache;
m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支
m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形)
const std::vector<double> noTicks;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], noTicks);
}
m_renderDecorOnly = false;
m_useDecorCache = savedUse;
} }
const int originX = static_cast<int>(std::floor(m_area.X));
const int originY = static_cast<int>(std::floor(m_area.Y)); // ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0
bool any = false; // (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。
// 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身
// 是画在不透明底板上的,这些像素本来就该不透明。
for (const gd::RectF& rect : m_decorRects) for (const gd::RectF& rect : m_decorRects)
{ {
const int x0 = static_cast<int>(std::floor(rect.X)) - originX; const int x0 = std::max(0, static_cast<int>(std::floor(rect.X)));
const int y0 = static_cast<int>(std::floor(rect.Y)) - originY; const int y0 = std::max(0, static_cast<int>(std::floor(rect.Y)));
const int x1 = static_cast<int>(std::ceil(rect.X + rect.Width)) - originX; const int x1 = std::min(width, static_cast<int>(std::ceil(rect.X + rect.Width)));
const int y1 = static_cast<int>(std::ceil(rect.Y + rect.Height)) - originY; const int y1 = std::min(height, static_cast<int>(std::ceil(rect.Y + rect.Height)));
if (x1 <= x0 || y1 <= y0) for (int y = y0; y < y1; ++y)
{ {
continue; unsigned char* row = static_cast<unsigned char*>(m_decorBits) +
(static_cast<std::size_t>(y) * static_cast<std::size_t>(width)) * 4u;
for (int x = x0; x < x1; ++x)
{
unsigned char* p = row + static_cast<std::size_t>(x) * 4u;
if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0)
{
p[3] = 255;
}
}
} }
BitBlt(m_decorDc, x0, y0, x1 - x0, y1 - y0, source, x0 + originX, y0 + originY, SRCCOPY);
any = true;
} }
g.ReleaseHDC(source);
m_decorValid = any; m_decorCapturedRects = m_decorRects;
if (any) m_decorValid = true;
{ return true;
// 与像素同时保存矩形列表与**坐标原点**:贴回时必须用这一份(见成员注释)。
m_decorCapturedRects = m_decorRects;
m_decorOriginX = originX;
m_decorOriginY = originY;
}
return any;
} }
bool PlotView::BlitDecorCache(gd::Graphics& g) bool PlotView::BlitDecorCache(gd::Graphics& g)
@@ -7278,29 +7399,41 @@ namespace jdi
{ {
return false; return false;
} }
void* proc = ResolveAlphaBlend();
if (proc == nullptr)
{
return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃)
}
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(); HDC target = g.GetHDC();
if (target == nullptr) if (target == nullptr)
{ {
return false; return false;
} }
// 用**捕获时**的原点推算源偏移,但按**绝对坐标**写入: // 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。
// 即使两帧的 `m_area` 不同(宽缓冲区渲染会临时改写它),装饰也不会错位。 BLENDFUNCTION fn = {};
fn.BlendOp = AC_SRC_OVER;
fn.SourceConstantAlpha = 255;
fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘)
bool any = false;
for (const gd::RectF& rect : m_decorCapturedRects) for (const gd::RectF& rect : m_decorCapturedRects)
{ {
const int destX = static_cast<int>(std::floor(rect.X)); const int x = static_cast<int>(std::floor(rect.X));
const int destY = static_cast<int>(std::floor(rect.Y)); const int y = static_cast<int>(std::floor(rect.Y));
const int srcX = destX - m_decorOriginX; const int w = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
const int srcY = destY - m_decorOriginY; const int h = static_cast<int>(std::ceil(rect.Y + rect.Height)) - y;
const int w = static_cast<int>(std::ceil(rect.X + rect.Width)) - destX;
const int h = static_cast<int>(std::ceil(rect.Y + rect.Height)) - destY;
if (w <= 0 || h <= 0) if (w <= 0 || h <= 0)
{ {
continue; continue;
} }
BitBlt(target, destX, destY, w, h, m_decorDc, srcX, srcY, SRCCOPY); if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE)
{
any = true;
}
} }
g.ReleaseHDC(target); g.ReleaseHDC(target);
return true; return any;
} }
bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height)
@@ -8788,7 +8921,10 @@ namespace jdi
// 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰), // 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰),
// 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`), // 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`),
// 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。 // 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。
if (!m_renderWide) // ⚠️ `m_renderDecorOnly`(把装饰单独画进透明缓存位图)时**也必须跳过**:
// 派生曲线是"随时间视界变化"的内容,若烘进装饰位图,贴回时会把旧视界的
// 预览曲线永久留在画面上。
if (!m_renderWide && !m_renderDecorOnly)
{ {
PaintLaneDerivedOverlays(g, laneIndex, lane); PaintLaneDerivedOverlays(g, laneIndex, lane);
} }
@@ -8877,35 +9013,18 @@ namespace jdi
pillZero.push_back(tick == 0.0); pillZero.push_back(tick == 0.0);
} }
// 记录胶囊矩形(供装饰层捕获/贴回;略微外扩以容纳圆角与文字边缘)。 // 记录胶囊矩形(供装饰层绘制/贴回;略微外扩以容纳圆角与文字边缘)。
for (const gd::RectF& pill : pills) for (const gd::RectF& pill : pills)
{ {
m_decorRects.push_back(gd::RectF(pill.X - 2.0f * m_scale, pill.Y - 1.0f * m_scale, m_decorRects.push_back(gd::RectF(pill.X - 2.0f * m_scale, pill.Y - 1.0f * m_scale,
pill.Width + 4.0f * m_scale, pill.Height + 2.0f * m_scale)); pill.Width + 4.0f * m_scale, pill.Height + 2.0f * m_scale));
} }
// 命中装饰缓存:跳过重绘(文字 + 圆角底板),由上层整块贴回。 // 命中装饰缓存:跳过重绘(文字 + 圆角底板),由上层整块贴回。
// ⚠️ 底板是**圆角且不透明**:装饰位图带 alpha,圆角之外必须保持全透明,
// 贴回时才能透出当前波形(避免"圆角外多出一圈直角白边")。
if (!m_useDecorCache) if (!m_useDecorCache)
{ {
if (!pills.empty()) UiFillRoundRects(g, pills, 3.0f * m_scale, m_theme->cardBg | 0xFF000000u);
{
// 胶囊底板**不透明**:装饰像素因此与时间视界无关,可以跨视界整块复用;
// 半透明底板会把旧视界的波形混进缓存像素,平移贴回时在底板内部留下残影。
// ⚠️ 先用不透明底色**铺满每个胶囊的外接矩形**(含圆角之外的几个像素),
// 再画圆角胶囊:否则圆角之外的波形像素会被装饰缓存烘进去,
// 平移贴回时在胶囊边缘留下一圈"旧波形残留"。
const std::uint32_t pillBase = m_theme->cardBg | 0xFF000000u;
for (const gd::RectF& pill : pills)
{
const gd::RectF outer(pill.X - 2.0f * m_scale, pill.Y - 1.0f * m_scale,
pill.Width + 4.0f * m_scale, pill.Height + 2.0f * m_scale);
UiFillRect(g,
gd::RectF(std::floor(outer.X) - 1.0f, std::floor(outer.Y) - 1.0f,
std::ceil(outer.X + outer.Width) - std::floor(outer.X) + 2.0f,
std::ceil(outer.Y + outer.Height) - std::floor(outer.Y) + 2.0f),
pillBase);
}
UiFillRoundRects(g, pills, 3.0f * m_scale, pillBase);
}
for (std::size_t index = 0; index < pills.size(); ++index) for (std::size_t index = 0; index < pills.size(); ++index)
{ {
const gd::RectF& pill = pills[index]; const gd::RectF& pill = pills[index];
@@ -9115,16 +9234,17 @@ namespace jdi
return gd::RectF(item.left - platePadX, top - platePadY, item.width + platePadX * 2.0f, return gd::RectF(item.left - platePadX, top - platePadY, item.width + platePadX * 2.0f,
height + platePadY * 2.0f); height + platePadY * 2.0f);
}; };
// 不透明底色矩形:把托底外接的**整数像素**再外扩 1px。装饰缓存的捕获边界由 // 条目托底 = 与 GX 编号徽标同一套风格的**圆角矩形**(用户明确要求:
// `floor/ceil` 得出,必须完全落在这块不透明填充之内,否则边缘会残留旧波形像素。 // "相当于一个 GX 标签,只是内容换成了图例信息")。
const auto OpaqueBaseRect = [](const gd::RectF& rect) const float plateRadius = kLegendPlateRadius * m_scale;
{ // 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。
const float x0 = std::floor(rect.X) - 1.0f; // ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图
const float y0 = std::floor(rect.Y) - 1.0f; // 却因不在贴回列表里而永远看不见。
const float x1 = std::ceil(rect.X + rect.Width) + 1.0f; const bool hasMoreText = (visibleCount > static_cast<int>(items.size())) &&
const float y1 = std::ceil(rect.Y + rect.Height) + 1.0f; (contentRight + 20.0f * m_scale <= maxX);
return gd::RectF(x0, y0, x1 - x0, y1 - y0); const bool hasEmptyText = (visibleCount == 0);
}; const gd::RectF moreTextRect(contentRight, top, 24.0f * m_scale, height);
const gd::RectF emptyTextRect(legendBand.X, top, lane.rect.Width, height);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 2) 命中矩形:**无论是否命中装饰缓存都必须重建**。 // 2) 命中矩形:**无论是否命中装饰缓存都必须重建**。
@@ -9149,11 +9269,13 @@ namespace jdi
LegendHit hit; LegendHit hit;
hit.ref = item.ref; hit.ref = item.ref;
hit.rect = PlateRect(item); hit.rect = PlateRect(item);
hit.radius = plateRadius;
hit.swatch = hit.swatch =
gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize); gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize);
hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top, hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top,
std::max(8.0f, item.textWidth + 2.0f), height); std::max(8.0f, item.textWidth + 2.0f), height);
hit.label = item.label; hit.label = item.label;
// 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。
hit.textColor = item.selected ? m_theme->textStrong : m_theme->text; hit.textColor = item.selected ? m_theme->textStrong : m_theme->text;
if (resolved.style != nullptr) if (resolved.style != nullptr)
{ {
@@ -9167,13 +9289,21 @@ namespace jdi
} }
} }
// 装饰矩形:**逐块托底**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 —— // 装饰矩形:**逐块**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 ——
// 覆盖整条带会把中间的波形像素也烘进缓存,平移贴回时露出"旧波形的像素残留"。 // 覆盖整条带没有必要,且逐块能减少装饰层贴图面积。
m_decorRects.push_back(badgeRect); m_decorRects.push_back(badgeRect);
for (const LegendItem& item : items) for (const LegendItem& item : items)
{ {
m_decorRects.push_back(PlateRect(item)); m_decorRects.push_back(PlateRect(item));
} }
if (hasEmptyText)
{
m_decorRects.push_back(emptyTextRect);
}
if (hasMoreText)
{
m_decorRects.push_back(moreTextRect);
}
// 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。 // 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。
if (m_useDecorCache) if (m_useDecorCache)
@@ -9182,14 +9312,14 @@ namespace jdi
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 3) 绘制。⚠️ 每块托底都先用**不透明底色铺满外接矩形**再画圆角: // 3) 绘制。托底是**圆角矩形**(与 GX 编号徽标同风格):
// 圆角之外的那几个像素若仍是波形,会被装饰缓存烘进去,平移贴回时表现为 // "一个 GX 标签,只是内容换成了图例信息"(用户明确要求)。
// "徽标 / 图例边缘残留旧波形"。底色 = 网格底色,铺满外接矩形在视觉上不可见。 // ⚠️ **不再铺任何外扩的不透明直角底色**:装饰位图带 alpha,圆角之外必须保持
// 全透明,贴回时才能透出**当前**波形 —— 否则圆角外会多出一圈"直角白边"。
// ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后, // ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后,
// 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。 // 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。
// ------------------------------------------------------------------ // ------------------------------------------------------------------
{ {
UiFillRect(g, OpaqueBaseRect(badgeRect), m_theme->cardBg | 0xFF000000u);
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg); UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
@@ -9203,10 +9333,11 @@ namespace jdi
continue; continue;
} }
const gd::RectF plate = PlateRect(item); const gd::RectF plate = PlateRect(item);
UiFillRect(g, OpaqueBaseRect(plate), m_theme->cardBg | 0xFF000000u); // 选中态 = 悬停态配色(`accentSoft` 底 + `accent` 描边),
UiFillRoundRect(g, plate, kLegendPlateRadius * m_scale, // 与 GX 编号徽标的鼠标悬停效果完全一致。
item.selected ? m_theme->selection : m_theme->cardBg); UiFillRoundRect(g, plate, plateRadius,
UiDrawRoundRect(g, plate, kLegendPlateRadius * m_scale, item.selected ? m_theme->accentSoft : m_theme->cardBg);
UiDrawRoundRect(g, plate, plateRadius,
item.selected ? m_theme->accent : m_theme->border, 1.0f); item.selected ? m_theme->accent : m_theme->border, 1.0f);
// 改名编辑态:整块托底变成输入框(与左侧列表双击改名一致)。 // 改名编辑态:整块托底变成输入框(与左侧列表双击改名一致)。
@@ -9226,23 +9357,17 @@ namespace jdi
} }
} }
if (visibleCount == 0) if (hasEmptyText)
{ {
UiText(g, L"(无可见信号)", m_fonts->uiSmall, UiText(g, L"(无可见信号)", m_fonts->uiSmall, emptyTextRect, m_theme->textMuted,
gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted,
TextAlign::LEFT); TextAlign::LEFT);
} }
else if (static_cast<int>(items.size()) < visibleCount) else if (hasMoreText)
{ {
// 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。
const std::wstring moreText = const std::wstring moreText =
L"+" + std::to_wstring(visibleCount - static_cast<int>(items.size())); L"+" + std::to_wstring(visibleCount - static_cast<int>(items.size()));
if (contentRight + 20.0f * m_scale <= maxX) UiText(g, moreText, m_fonts->uiSmall, moreTextRect, m_theme->textMuted, TextAlign::LEFT);
{
UiText(g, moreText, m_fonts->uiSmall,
gd::RectF(contentRight, top, 24.0f * m_scale, height), m_theme->textMuted,
TextAlign::LEFT);
}
} }
} }
+97
View File
@@ -801,6 +801,102 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
--- ---
### 9.20 装饰层改为「带 alpha 的独立图层」+ 图例圆角托底 + 预览曲线内存释放(本轮)
#### 一、GX 编号 / 图例「圆角外多出一圈直角白边」的根因与修复
- **症状**:把密集波形(如 `VERY_BIG_DATA` 的编码器信号)平移到 GX 编号与图例下方时,
明明是圆角矩形的 GX 标签 / 图例托底,**外边却多出一个直角长方形**(浅色底 = 网格底色),非常突兀。
- **根因**:装饰层原来是「**从后备缓冲区抓取一块不透明矩形**」——
`CaptureDecorCache()` 用 `BitBlt` 把 `m_decorRects`(矩形)里的像素搬进 `m_decorDc`。
要让这块**矩形**像素跨视界可复用(不残留旧波形),又必须在圆角矩形之外**铺一层不透明底色**
(`OpaqueBaseRect`,比元素大 1px 的直角矩形)。两者叠加 ⇒ 圆角之外是"网格底色"而不是波形
⇒ 在密集波形上就是**一圈直角白边**。Y 轴刻度胶囊同理。
- **修法:把装饰层改成带真 alpha 的独立图层**(不再"抓矩形"):
1. `m_decorDc` 改为 **32bpp `CreateDIBSection`**(自上而下),像素可直接读写 alpha;
2. 新增 **`RenderDecorCache()`**:把装饰**单独绘制**进一张全透明的 DIB(`memset` 清零后
`m_renderDecorOnly = true` 逐网格 `PaintLane`)—— 圆角之外保持全透明;
⚠️ 位图覆盖范围 = **客户区原点 → 绘图区右下角**,绘制坐标与正常绘制**完全一致**
(GDI 文本不认 GDI+ 变换,绝不能做坐标平移);
3. `BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,GDI+ 写入 DIB 段即**预乘** alpha)
逐块合成 ⇒ 圆角之外透出的是**当前**波形;
4. **`AlphaBlend` 用 `GetProcAddress` 解析**(`gdi32!GdiAlphaBlend`,回退 `msimg32!AlphaBlend`),
**不新增链接依赖**(两台协作设备工具链不同,避免改 Makefile);
5. ⚠️ **alpha 修补**:GDI 文本(`DrawTextW` / `ExtTextOutW`)会把写入像素的 alpha **清成 0**
(GDI 不维护 alpha),若不修补,文字位置会变成"透明洞"(透出波形)。
因此在装饰矩形内把「`A == 0` 且有颜色(`RGB != 0`)」的像素还原为不透明 ——
装饰本就画在不透明底板上,这些像素本来就该不透明;
6. 删除全部 `OpaqueBaseRect`(外扩的直角不透明底色)。Y 轴胶囊与图例托底都只画**圆角形状本身**。
- **顺带修好的一处遗漏**:图例的「`+N`」(空间不足提示)与「(无可见信号)」也是装饰层内容,
必须**登记进 `m_decorRects`**,否则会被画进装饰位图却因不在贴回列表里而永远看不见。
#### 二、图例样式:与 GX 编号徽标同款圆角托底 + 悬停/选中配色统一
- 每个图例条目 = **一个圆角矩形托底**(`kLegendPlateRadius = 4`,与 GX 徽标同风格的
`cardBg` 底 + `border` 描边),内容 = 色块 + 信号名 —— 用户要求"近似于一个 GX 标签,
只是内容换成了图例信息"。
- **选中态与悬停态同配色**(用户明确要求"选中状态与鼠标悬停时的提示效果相同"):
`accentSoft` 底 + `accent` 描边 + `textStrong` 文字 —— 与 GX 徽标的悬停效果完全一致。
- 悬停高亮画在**贴图之后**(叠加层):先铺不透明 `accentSoft` 圆角矩形 + `accent` 描边,
再在其上**原样重绘色块与文字**(否则不透明托底会把内容盖住),并保持**对应信号的曲线光晕**
(`PaintSignalHaloFor`)。`LegendHit` 新增 `radius` 字段供悬停重绘使用。
- 选中态与左侧信号列表**同源**(`DataSet::Selected()`):面板选中 → 图例高亮;图例点选 →
面板行选中。左右一致由同一条数据保证。
- ⚠️ 沿用上一轮的硬约束:`m_legendHits` **必须**在**任何**绘制路径(含装饰缓存命中)先重建。
#### 三、预览曲线内存:thread_local 工作缓冲封顶 + 关闭功能即释放
- **症状**(用户报告):`VERY_BIG_DATA` 加载并显示全部信号 ≈1.42GB;对某个网格开启弱峰调整
(带预览曲线)后立刻 ≈2.17GB;迭代次数拉到 10 后 ≈4.21GB;**点浮动面板的 ✕ 关掉预览,内存仍然
不下降**;只有"关闭整个文件分组"才释放。
- **根因**:`Utilities/Src/DataModel.cpp` 里三处 `thread_local` **工作缓冲**(弱峰调整的
`currentBuffer`/`nextBuffer`/`extremesBuffer`、`ApplyWeakPeakStage` 的 `shiftBuffer`、
迭代均值滤波的 `bufferBuffer`)。整段信号(2580 万采样)计算时,每条后台线程会常驻
**数百 MB**;后台曲线线程池有 **4 条线程** ⇒ 2GB 以上。而 `thread_local` **只在线程退出时**
才析构 ⇒ 关掉预览不释放,只有 `PauseDerivedAsync()`(关文件分组会调用)join 线程后才归还。
- **修法(两处)**:
1. **保留容量封顶**:新增 `kReuseBufferMaxElements = 4M` + `TrimReuseBuffer` +
`ReuseBufferScope<T>`(作用域守卫,**所有退出路径**都会裁剪)。容量超上限的"超大计算"
改为**一次性分配、用完即释放** —— 这类整段计算本身是秒级的一次性操作(结果会被曲线缓存复用),
内存不再随线程数放大;中/小规模仍保留容量以维持"多阶弱峰调整 991ms → 577ms"的收益。
2. **关闭功能即释放**:新增 `AnyDerivedPreviewEnabled()`(本标签页是否还有网格开启
弱峰调整 / 均值滤波 / 边沿计数)与 **`TrimDerivedCurveDataIfIdle()`**;在
`SetLaneWeakPeak/SetLaneMeanFilter/SetLaneEdgeCenterCount(..., false)` 里调用 ——
**关闭最后一个预览功能时立即 `PauseDerivedAsync()`**(join 线程 ⇒ thread_local 析构 ⇒
内存归还)并清空均值/弱峰曲线缓存。
- **实测**(新增 `.preflight/perf/stylecheck` mode 2,`VERY_BIG_DATA`):
`baseline 1202MB → 弱峰(10 迭代, 1 条) 1399MB → 5 条曲线 1792MB → **关闭面板后 1203MB**`
(修复前该值不下降)。5 条整段曲线的驻留仍受曲线缓存预算(`kCurveSpanCacheMaxBytes` 768MB)约束。
- ⚠️ **诚实说明:计算期间的瞬时峰值仍可达 ≈2.2GB**(同一进程重复测量为 1.4~2.2GB)——
一次整段弱峰调整需要 `out` + 2 个 float 工作缓冲(各 ≈103MB / 2580 万采样),
多线程/多次重排时会有 2 份同时在算。这是"整段 O(n) 计算"的固有代价
(要彻底消除需把算法分块或限制大任务的并发度),**但它在计算结束后立刻归还**,
不再像修复前那样永久驻留。
#### 四、性能与保真度实测(`stylecheck` / `zoomcheck`)
| 指标 | 结果 |
| --- | --- |
| `RenderDecorCache`(重建装饰位图,仅装饰内容变化时执行) | **2.40ms**(5 网格) |
| `BlitDecorCache`(**每帧**贴回,`AlphaBlend`) | **0.19ms**(原 `BitBlt` 方案约 0.3ms) |
| `zoomcheck` mode 13(收敛帧 vs 整幅重绘) | **逐像素 0 差异**(此前为 3503 像素) |
#### 五、验证(全部离屏,不占用鼠标/键盘/屏幕)
- `.preflight/perf/verify_main.cpp`(`build.ps1 -Main verify_main.cpp -Out verify.exe`)
**49/49 通过**,本轮新增/强化:
* **装饰位图圆角外 alpha ≈ 0、中心 alpha = 255**(直接读 `m_decorBits`)—— 直接证明"没有直角白边";
* **选中图例 = 悬停配色**(在托底矩形内统计 `accentSoft` 像素:未选中 0、选中 1525);
* **平移(宽基底)帧的装饰与整幅重绘逐像素一致**(只比较装饰**不透明**像素:`worst=1 / 29554 px`;
圆角外的波形属于已知的 ≤0.5px 整数像素平移残差,不参与比较);
* **图例溢出的「+N」提示**:在窄布局 + 合并网格下断言提示矩形已登记且可见(墨迹 570px);
* 图例悬停可见高亮(2011px)、GX 徽标悬停可见高亮(1122px)、装饰矩形内 0 像素残留。
- `.preflight/perf/stylecheck_main.cpp`:mode 0(装饰重建/贴回耗时)、mode 1(密集波形下导出
GX+图例角落 PNG 供目视)、mode 2(预览曲线内存增长/释放)、mode 4(装饰位图 alpha 形状检查)。
- `make release` 零警告。
---
## 10. 变更日志(本文件维护记录) ## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 | | 日期 | 设备/模型 | 摘要 |
@@ -832,6 +928,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 |
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 |
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash | **装饰层改为带 alpha 的独立图层(消除 GX/图例外围"直角白边")+ 图例圆角托底与悬停/选中配色统一 + 预览曲线内存释放(详见 §9.20)**。① **白边根因**:装饰层原为"从后备缓冲区抓取一块**不透明矩形**",圆角之外(属于波形)的像素被一起烘进缓存,为让其跨视界可复用又在圆角外铺了直角不透明底色 ⇒ 密集波形上就是一圈直角白边。**修法**:`m_decorDc` 改 **32bpp DIBSection**、新增 `RenderDecorCache()` 把装饰**单独绘制**进全透明位图(不再抓取)、`BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,`GetProcAddress(gdi32!GdiAlphaBlend)` 解析、不新增链接依赖)⇒ 圆角外透出**当前**波形;并做 **alpha 修补**(GDI 文本会把写入像素的 alpha 清 0,不修补文字会变透明洞);删除全部外扩直角底色。② **图例**:逐条目圆角托底(与 GX 徽标同风格),**选中态 = 悬停态配色**(`accentSoft`+`accent`+`textStrong`),悬停在不透明托底上重绘色块/文字并保留曲线光晕;`LegendHit` 新增 `radius`;补齐「+N」/「(无可见信号)」提示的装饰矩形。③ **内存**:`thread_local` 工作缓冲**保留容量封顶**(`kReuseBufferMaxElements` + `ReuseBufferScope`,超大计算用完即释放)+ 关闭最后一个预览功能时 `TrimDerivedCurveDataIfIdle()`(`PauseDerivedAsync` 让线程退出、thread_local 析构归还内存)—— 实测 1792MB → **关闭后 1203MB**(修复前不下降)。④ 性能:`BlitDecorCache` **0.19ms/帧**、`RenderDecorCache` 2.4ms(仅内容变化时);`zoomcheck` mode 13 收敛帧与整幅重绘**逐像素 0 差异**(此前 3503)。验证:离屏 `verify.exe` **49/49 通过**、`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹),仅 `offsetTime` 因共用文件时间轴仍需继承。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹),仅 `offsetTime` 因共用文件时间轴仍需继承。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 |
--- ---
+59
View File
@@ -1014,6 +1014,57 @@ namespace jdi
} }
} // namespace } // namespace
namespace
{
/**
* @brief `thread_local` 复用缓冲的**保留容量上限**(元素个数,约 4M)。
*
* 这些缓冲用 `thread_local` 复用,是为了省掉"每次调用都分配 + 零初始化上千万元素"
* 的开销(多阶弱峰调整实测 991ms → 577ms)。但**保留容量必须封顶**:
* 后台派生曲线线程池有 4 条线程,每条各自持有一份 `thread_local` 缓冲;
* 一条"整段信号"(VERY_BIG_DATA ≈ 2580 万采样)的弱峰调整需要
* `current` / `next`(float,各 ≈103MB)、极值表与位移表(≈数百 MB),
* 于是**每条线程常驻数百 MB、4 条线程就是 2GB 以上**,而 `thread_local`
* 只在**线程退出**时才析构 → 用户报告"启用预览后内存 1.42GB→4.21GB,
* 关掉预览也不下降,直到关闭整个文件分组才释放"。
*
* 因此只对"中/小规模"(≤ 上限)保留容量;**超大计算**改为一次性分配、用完即释放 ——
* 这类整段计算本身是秒级的一次性操作(结果会被曲线缓存复用),多一次分配完全可接受,
* 换来的是内存不再随线程数放大。
*/
const std::size_t kReuseBufferMaxElements = 4u * 1024u * 1024u;
/** @brief 容量超过上限就释放缓冲内存(`std::vector` 的容量不会随 `clear()` 归还)。 */
template <typename T>
void TrimReuseBuffer(std::vector<T>& buffer)
{
if (buffer.capacity() > kReuseBufferMaxElements)
{
std::vector<T> empty;
buffer.swap(empty);
}
}
/**
* @brief 作用域守卫:离开作用域时对 `thread_local` 复用缓冲做一次容量裁剪。
*
* 用它(而不是在每个 `return` 前手写一遍)可以保证**所有**退出路径都会裁剪,
* 不会因为新增提前返回而漏掉、导致内存再次失控。
*/
template <typename T>
class ReuseBufferScope
{
public:
explicit ReuseBufferScope(std::vector<T>& buffer) : m_buffer(buffer) {}
~ReuseBufferScope() { TrimReuseBuffer(m_buffer); }
ReuseBufferScope(const ReuseBufferScope&) = delete;
ReuseBufferScope& operator=(const ReuseBufferScope&) = delete;
private:
std::vector<T>& m_buffer;
};
} // namespace
void ComputeMovingAverageIterated(const std::vector<double>& values, int radius, int iterations, void ComputeMovingAverageIterated(const std::vector<double>& values, int radius, int iterations,
std::vector<float>& out) std::vector<float>& out)
{ {
@@ -1027,7 +1078,9 @@ namespace jdi
// 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。 // 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。
// 中转缓冲复用容量(线程本地),避免每阶重新分配上千万元素的向量。 // 中转缓冲复用容量(线程本地),避免每阶重新分配上千万元素的向量。
// ⚠️ 用 `ReuseBufferScope` 保证离开作用域时对超大容量做裁剪(见其说明)。
thread_local std::vector<double> bufferBuffer; thread_local std::vector<double> bufferBuffer;
ReuseBufferScope<double> reuseScope(bufferBuffer);
std::vector<double>& buffer = bufferBuffer; std::vector<double>& buffer = bufferBuffer;
buffer.assign(values.size(), std::numeric_limits<double>::quiet_NaN()); buffer.assign(values.size(), std::numeric_limits<double>::quiet_NaN());
std::vector<float> stage; std::vector<float> stage;
@@ -1159,6 +1212,7 @@ namespace jdi
// 2) 逐个极值点判定它属于哪几类规则点,并算出它自己的目标偏移量。 // 2) 逐个极值点判定它属于哪几类规则点,并算出它自己的目标偏移量。
const std::size_t extremeCount = extremes.size(); const std::size_t extremeCount = extremes.size();
thread_local std::vector<double> shiftBuffer; thread_local std::vector<double> shiftBuffer;
ReuseBufferScope<double> reuseScope(shiftBuffer);
std::vector<double>& shift = shiftBuffer; std::vector<double>& shift = shiftBuffer;
shift.assign(extremeCount, 0.0); shift.assign(extremeCount, 0.0);
for (std::size_t k = 2; k + 2 < extremeCount; ++k) for (std::size_t k = 2; k + 2 < extremeCount; ++k)
@@ -1324,9 +1378,14 @@ namespace jdi
// 第 1 阶:从原始采样值出发(非有限值保持 NaN)。 // 第 1 阶:从原始采样值出发(非有限值保持 NaN)。
// 工作缓冲用线程本地变量复用容量:单条曲线动辄上千万采样,若每次调用都重新分配 // 工作缓冲用线程本地变量复用容量:单条曲线动辄上千万采样,若每次调用都重新分配
// 并零初始化,单条「弱峰调整」的耗时会被明显放大(内存分配 + page fault)。 // 并零初始化,单条「弱峰调整」的耗时会被明显放大(内存分配 + page fault)。
// ⚠️ 用 `ReuseBufferScope` 保证**离开作用域时裁剪超大容量**:否则 4 条后台线程
// 会各自常驻数百 MB(thread_local 只在线程退出时才析构),内存随线程数放大。
thread_local std::vector<float> currentBuffer; thread_local std::vector<float> currentBuffer;
thread_local std::vector<float> nextBuffer; thread_local std::vector<float> nextBuffer;
thread_local std::vector<PeakExtremum> extremesBuffer; thread_local std::vector<PeakExtremum> extremesBuffer;
ReuseBufferScope<float> currentScope(currentBuffer);
ReuseBufferScope<float> nextScope(nextBuffer);
ReuseBufferScope<PeakExtremum> extremesScope(extremesBuffer);
std::vector<float>& current = currentBuffer; std::vector<float>& current = currentBuffer;
current.assign(count, std::numeric_limits<float>::quiet_NaN()); current.assign(count, std::numeric_limits<float>::quiet_NaN());
for (std::size_t index = 0; index < count; ++index) for (std::size_t index = 0; index < count; ++index)