装饰层改用带 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();
/**
* @brief 当前标签页是否还有任何「带预览曲线」的实验性功能处于开启状态。
* @return 至少一个网格开启了弱峰调整 / 均值滤波 / 边沿计数(中心轴)返回 true。
*/
bool AnyDerivedPreviewEnabled() const;
/**
* @brief 若已无任何预览曲线功能,立即释放派生曲线的全部数据。
*
* 释放内容包括:后台线程池(**join 之后各线程的 `thread_local` 工作缓冲才析构、
* 内存才归还**)、待算队列、已算好的曲线条目与均值/弱峰曲线缓存。
*
* ⚠️ 这是"关掉预览后内存不下降"的修复:一条整段(千万级采样)曲线的中间缓冲
* 可达数百 MB,而 `thread_local` 只在线程退出时释放 —— 过去只有"
* 关闭整个文件分组"(会 `PauseDerivedAsync`)才会真正还内存。
*/
void TrimDerivedCurveDataIfIdle();
/**
* @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。
* @return 状态发生变化返回 true。
@@ -1878,6 +1896,7 @@ namespace jdi
{
SignalRef ref; /**< 对应信号。 */
gd::RectF rect; /**< 条目托底矩形(同时是命中区域)。 */
float radius = 0.0f; /**< 托底圆角半径(悬停高亮重绘时使用)。 */
gd::RectF swatch; /**< 色块矩形。 */
gd::RectF text; /**< 文本矩形。 */
std::wstring label; /**< 已经过省略处理的显示文本。 */
@@ -2295,18 +2314,21 @@ namespace jdi
*/
bool m_sceneDegraded = false;
// ---- 「装饰层」像素缓存(Y 轴刻度胶囊 + 图例)----
// 装饰(Y 轴刻度文字与其圆角底板、图例)只取决于网格布局 / 幅值范围 / 信号外观与
// 图例文案,与时间视界无关;但它**不适合**放进「宽基底」:平移是从基底里截取子矩形,
// 烘进基底的装饰会跟着一起移动 → 在网格左缘之外留下一排旧图例/刻度(重影)。
// 因此单独缓存一帧**按当前视界绘制**的装饰像素,并在内容指纹完全一致时整块贴回。
// ---- 「装饰层」像素缓存(Y 轴刻度胶囊 + GX 编号 + 图例)----
// 装饰只取决于网格布局 / 幅值范围 / 信号外观与图例文案,与时间视界无关;但它
// **不适合**放进「宽基底」:平移是从基底里截取子矩形,烘进基底的装饰会跟着一起
// 移动 → 在网格左缘之外留下一排旧图例/刻度(重影)。因此单独缓存一层的像素。
//
// ⚠️ 关键不变量:**每一帧“真正绘制了装饰”都要重新捕获**(无论是否快速帧)。
// 只要保证“缓存像素”与“缓存指纹”永远同源,贴回就不可能过期;
// 反之(例如只在全质量帧捕获)会在几何/外观变化后贴回旧像素,
// 表现为残留 / 黑块 / 错位等错误元素。
// ⚠️ **本缓存带真 Alpha**(32bpp DIB + `AlphaBlend`),而不是"从后备缓冲区抓一块不透明
// 矩形":装饰是**圆角**图形,抓矩形会把圆角之外(属于波形)的像素也一并烘进缓存,
// 平移贴回时露出"圆角矩形外围多出一圈直角白边 / 旧波形像素残留"(用户报告)。
// 现在改为:**把装饰单独绘制进一张透明 DIB**(圆角之外保持全透明),贴回时用
// `AlphaBlend` 合成 ⇒ 圆角之外透出的是**当前**波形,既干净又可跨视界复用。
//
// ⚠️ 关键不变量:**每一帧要显示装饰时,都用 `m_decorKey == SceneDecorHash()` 判定缓存
// 是否同源**;不同源就必须先 `RenderDecorCache()` 再贴回。
/**
* @brief 确保装饰缓存位图尺寸正确(不足则重建)。
* @brief 确保装饰缓存位图尺寸正确(不足则重建为 32bpp 的 DIB 段)。
* @param [IN] width 位图宽度。 @param [IN] height 位图高度。
* @return 可用返回 true。
*/
@@ -2314,42 +2336,47 @@ namespace jdi
/** @brief 释放装饰缓存位图并作废缓存。 */
void ReleaseDecorCache();
/**
* @brief 把本帧记录下来的装饰矩形从后备缓冲区捕获进装饰缓存。
* @param [IN,OUT] g 绘图上下文。 @return 捕获成功返回 true。
* @brief 把装饰(Y 轴胶囊 + GX 编号 + 图例)**单独绘制**进透明的装饰缓存位图。
*
* 位图覆盖范围 = 客户区原点到绘图区右下角(`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。
*/
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 HBITMAP m_decorBitmap = nullptr; /**< 装饰缓存位图。 */
mutable HGDIOBJ m_decorOldBitmap = nullptr; /**< 装饰 DC 原选位图。 */
mutable void* m_decorBits = nullptr; /**< 位图像素(32bpp DIB 段,可写 alpha)。 */
mutable int m_decorW = 0; /**< 装饰缓存宽度。 */
mutable int m_decorH = 0; /**< 装饰缓存高度。 */
mutable bool m_decorValid = false; /**< 装饰缓存内容是否有效。 */
mutable std::uint64_t m_decorKey = 0; /**< 装饰缓存对应的内容指纹。 */
mutable bool m_useDecorCache = false; /**< 本帧是否跳过绘制装饰、改用缓存贴回。 */
/** 本帧需要捕获的装饰矩形(各网格的 Y 轴胶囊与图例带)。 */
/** 本帧需要捕获的装饰矩形(各网格的 Y 轴胶囊 + GX 编号 + 图例托底)。 */
mutable std::vector<gd::RectF> m_decorRects;
/**
* @brief 缓存里那批像素对应的装饰矩形(捕获时与像素同时保存)。
* @brief 缓存里那批像素对应的装饰矩形(与像素同时生成)。
*
* ⚠️ 贴回必须用这一份,**不能**用当前的 `m_decorRects`:
* 命中缓存时各网格会跳过绘制、根本不会重建矩形列表(为空),
* 若用当前列表就会贴错位置或一个都不贴(表现为装饰消失 / 黑块 / 错位)。
*/
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;
// ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ----
//