装饰层改用带 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;
// ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ----
//
+254 -129
View File
@@ -1328,6 +1328,35 @@ namespace jdi
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)
{
if (m_dataSet == nullptr || m_tab == nullptr)
@@ -1896,6 +1925,12 @@ namespace jdi
{
lane.edgeCenterPanelOpen = false;
}
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
if (!enabled)
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
RebuildWideBuffer();
return true;
@@ -1984,6 +2019,12 @@ namespace jdi
{
lane.weakPeakPanelOpen = false;
}
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
if (!enabled)
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」(不含派生曲线),使用户首次拖动该滑块时无需从零重建(避免一次性尖峰)。
RebuildWideBuffer();
return true;
@@ -2067,6 +2108,12 @@ namespace jdi
{
lane.meanFilterPanelOpen = false;
}
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
if (!enabled)
{
TrimDerivedCurveDataIfIdle();
}
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
RebuildWideBuffer();
return true;
@@ -6053,28 +6100,31 @@ namespace jdi
{
PaintLaneDerivedOverlays(g, static_cast<int>(i), m_lanes[i]);
}
// 装饰(Y 轴刻度 + 图例):基底不含(否则平移会重影)。
// 内容指纹一致 → 直接整块贴回缓存(省掉占整幅渲染一半以上的文字绘制);
// 否则用 `m_renderDecorOnly` 重画一遍并**立即重新捕获**。
// 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。
// 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制);
// 否则先把装饰单独绘制进透明缓存位图,再贴回。
// ⚠️ 贴回的是**带 alpha 的圆角图形**:圆角之外是全透明,
// 因此透出的是**当前**波形 —— 既没有"直角白边",也不会残留旧波形像素。
m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash());
if (!m_useDecorCache && RenderDecorCache())
{
m_decorKey = SceneDecorHash();
m_useDecorCache = true;
}
if (m_useDecorCache)
{
// 贴回用**捕获时**的矩形(命中缓存时各网格不会重建矩形列表)。
BlitDecorCache(g);
}
else
{
m_decorRects.clear();
// 退化路径(无法建立透明缓存,例如系统不提供 AlphaBlend):
// 直接把装饰画进后备缓冲区(就地绘制,不做缓存)。
m_renderDecorOnly = true;
for (std::size_t i = 0; i < m_lanes.size(); ++i)
{
PaintLane(g, static_cast<int>(i), m_lanes[i], wideTimeTicks);
}
m_renderDecorOnly = false;
if (CaptureDecorCache(g))
{
m_decorKey = SceneDecorHash();
}
}
paintOverlays();
if (morePending)
@@ -6127,10 +6177,10 @@ namespace jdi
// 图例命中矩形每帧重建,避免残留已消失的信号。
m_legendHits.clear();
// 「装饰层」缓存决策:快速帧(拖拽/平移)期间、且装饰内容与上次捕获完全一致时,
// 跳过 Y 轴胶囊与图例的绘制(约占整幅重绘的一半),改用上次捕获的像素贴回。
// 「装饰层」:装饰**不在网格循环里就地绘制**,而是统一由带 alpha 的装饰层贴回
// (这样圆角之外透出的是当前波形,不会出现"直角白边")。
m_decorRects.clear();
m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash());
m_useDecorCache = true;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
@@ -6139,16 +6189,15 @@ namespace jdi
PaintTimeAxis(g, timeTicks, timeStep);
// 装饰层:命中缓存则上一轮网格循环已经跳过绘制,这里整块贴回;
// 否则本轮已经真正绘制了装饰,立即捕获(保证像素与指纹同源)。
if (m_useDecorCache)
// 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。
if (!m_decorValid || m_decorKey != SceneDecorHash())
{
BlitDecorCache(g);
}
else if (CaptureDecorCache(g))
{
m_decorKey = SceneDecorHash();
if (RenderDecorCache())
{
m_decorKey = SceneDecorHash();
}
}
BlitDecorCache(g);
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
@@ -6274,11 +6323,8 @@ namespace jdi
if (badge.Width > 0.0f)
{
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);
UiDrawRoundRect(g, badge, radius, m_theme->accent, 1.0f);
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)
{
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);
UiDrawRoundRect(g, hit.rect, radius, m_theme->accent, 1.0f);
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;
}
}
@@ -6771,6 +6818,11 @@ namespace jdi
std::vector<double> timeTicks;
BuildTicks(viewMin, viewMax, timeStep, timeTicks);
// ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格
// (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖,
// 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。
const bool savedUseDecorCache = m_useDecorCache;
m_useDecorCache = false;
for (const int laneIndex : repaint)
{
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);
}
}
m_useDecorCache = savedUseDecorCache;
g.Restore(saveState);
}
@@ -7176,7 +7229,7 @@ namespace jdi
{
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;
}
@@ -7186,15 +7239,37 @@ namespace jdi
{
return false;
}
m_decorDc = CreateCompatibleDC(screen);
m_decorBitmap = CreateCompatibleBitmap(screen, width, height);
// ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`):
// 装饰要带真 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);
if (m_decorDc == nullptr || m_decorBitmap == nullptr)
if (dib == nullptr || bits == nullptr)
{
ReleaseDecorCache();
if (dib != nullptr)
{
DeleteObject(dib);
}
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_decorH = height;
return true;
@@ -7219,57 +7294,103 @@ namespace jdi
DeleteObject(m_decorBitmap);
m_decorBitmap = nullptr;
}
m_decorBits = nullptr;
m_decorW = 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;
}
// 用绘图区矩形作为缓存位图尺寸(装饰都在绘图区内)。
const int width = static_cast<int>(std::ceil(m_area.Width));
const int height = static_cast<int>(std::ceil(m_area.Height));
// 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致,
// 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。
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))
{
m_decorValid = 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;
return false;
gd::Graphics g(m_decorDc);
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));
bool any = false;
// ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0
// (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。
// 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身
// 是画在不透明底板上的,这些像素本来就该不透明。
for (const gd::RectF& rect : m_decorRects)
{
const int x0 = static_cast<int>(std::floor(rect.X)) - originX;
const int y0 = static_cast<int>(std::floor(rect.Y)) - originY;
const int x1 = static_cast<int>(std::ceil(rect.X + rect.Width)) - originX;
const int y1 = static_cast<int>(std::ceil(rect.Y + rect.Height)) - originY;
if (x1 <= x0 || y1 <= y0)
const int x0 = std::max(0, static_cast<int>(std::floor(rect.X)));
const int y0 = std::max(0, static_cast<int>(std::floor(rect.Y)));
const int x1 = std::min(width, static_cast<int>(std::ceil(rect.X + rect.Width)));
const int y1 = std::min(height, static_cast<int>(std::ceil(rect.Y + rect.Height)));
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;
if (any)
{
// 与像素同时保存矩形列表与**坐标原点**:贴回时必须用这一份(见成员注释)。
m_decorCapturedRects = m_decorRects;
m_decorOriginX = originX;
m_decorOriginY = originY;
}
return any;
m_decorCapturedRects = m_decorRects;
m_decorValid = true;
return true;
}
bool PlotView::BlitDecorCache(gd::Graphics& g)
@@ -7278,29 +7399,41 @@ namespace jdi
{
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();
if (target == nullptr)
{
return false;
}
// 用**捕获时**的原点推算源偏移,但按**绝对坐标**写入:
// 即使两帧的 `m_area` 不同(宽缓冲区渲染会临时改写它),装饰也不会错位。
// 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。
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)
{
const int destX = static_cast<int>(std::floor(rect.X));
const int destY = static_cast<int>(std::floor(rect.Y));
const int srcX = destX - m_decorOriginX;
const int srcY = destY - m_decorOriginY;
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;
const int x = static_cast<int>(std::floor(rect.X));
const int y = static_cast<int>(std::floor(rect.Y));
const int w = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
const int h = static_cast<int>(std::ceil(rect.Y + rect.Height)) - y;
if (w <= 0 || h <= 0)
{
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);
return true;
return any;
}
bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height)
@@ -8788,7 +8921,10 @@ namespace jdi
// 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰),
// 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`),
// 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。
if (!m_renderWide)
// ⚠️ `m_renderDecorOnly`(把装饰单独画进透明缓存位图)时**也必须跳过**:
// 派生曲线是"随时间视界变化"的内容,若烘进装饰位图,贴回时会把旧视界的
// 预览曲线永久留在画面上。
if (!m_renderWide && !m_renderDecorOnly)
{
PaintLaneDerivedOverlays(g, laneIndex, lane);
}
@@ -8877,35 +9013,18 @@ namespace jdi
pillZero.push_back(tick == 0.0);
}
// 记录胶囊矩形(供装饰层捕获/贴回;略微外扩以容纳圆角与文字边缘)。
// 记录胶囊矩形(供装饰层绘制/贴回;略微外扩以容纳圆角与文字边缘)。
for (const gd::RectF& pill : pills)
{
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));
}
// 命中装饰缓存:跳过重绘(文字 + 圆角底板),由上层整块贴回。
// ⚠️ 底板是**圆角且不透明**:装饰位图带 alpha,圆角之外必须保持全透明,
// 贴回时才能透出当前波形(避免"圆角外多出一圈直角白边")。
if (!m_useDecorCache)
{
if (!pills.empty())
{
// 胶囊底板**不透明**:装饰像素因此与时间视界无关,可以跨视界整块复用;
// 半透明底板会把旧视界的波形混进缓存像素,平移贴回时在底板内部留下残影。
// ⚠️ 先用不透明底色**铺满每个胶囊的外接矩形**(含圆角之外的几个像素),
// 再画圆角胶囊:否则圆角之外的波形像素会被装饰缓存烘进去,
// 平移贴回时在胶囊边缘留下一圈"旧波形残留"。
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);
}
UiFillRoundRects(g, pills, 3.0f * m_scale, m_theme->cardBg | 0xFF000000u);
for (std::size_t index = 0; index < pills.size(); ++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,
height + platePadY * 2.0f);
};
// 不透明底色矩形:把托底外接的**整数像素**再外扩 1px。装饰缓存的捕获边界由
// `floor/ceil` 得出,必须完全落在这块不透明填充之内,否则边缘会残留旧波形像素。
const auto OpaqueBaseRect = [](const gd::RectF& rect)
{
const float x0 = std::floor(rect.X) - 1.0f;
const float y0 = std::floor(rect.Y) - 1.0f;
const float x1 = std::ceil(rect.X + rect.Width) + 1.0f;
const float y1 = std::ceil(rect.Y + rect.Height) + 1.0f;
return gd::RectF(x0, y0, x1 - x0, y1 - y0);
};
// 条目托底 = 与 GX 编号徽标同一套风格的**圆角矩形**(用户明确要求:
// "相当于一个 GX 标签,只是内容换成了图例信息")。
const float plateRadius = kLegendPlateRadius * m_scale;
// 「+N」(图例空间不足)与「(无可见信号)」提示的矩形。
// ⚠️ 它们同样属于装饰层、必须登记进 `m_decorRects`:否则会被画进装饰位图
// 却因不在贴回列表里而永远看不见。
const bool hasMoreText = (visibleCount > static_cast<int>(items.size())) &&
(contentRight + 20.0f * m_scale <= maxX);
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) 命中矩形:**无论是否命中装饰缓存都必须重建**。
@@ -9149,11 +9269,13 @@ namespace jdi
LegendHit hit;
hit.ref = item.ref;
hit.rect = PlateRect(item);
hit.radius = plateRadius;
hit.swatch =
gd::RectF(item.left, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize);
hit.text = gd::RectF(item.left + swatchSize + 5.0f * m_scale, top,
std::max(8.0f, item.textWidth + 2.0f), height);
hit.label = item.label;
// 「选中」与「悬停」用同一套配色(用户明确要求),因此文字色在这里也按选中态取。
hit.textColor = item.selected ? m_theme->textStrong : m_theme->text;
if (resolved.style != nullptr)
{
@@ -9167,13 +9289,21 @@ namespace jdi
}
}
// 装饰矩形:**逐块托底**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 ——
// 覆盖整条带会把中间的波形像素也烘进缓存,平移贴回时露出"旧波形的像素残留"。
// 装饰矩形:**逐块**(徽标一块 + 每个条目一块),而不是覆盖整条图例带 ——
// 覆盖整条带没有必要,且逐块能减少装饰层贴图面积。
m_decorRects.push_back(badgeRect);
for (const LegendItem& item : items)
{
m_decorRects.push_back(PlateRect(item));
}
if (hasEmptyText)
{
m_decorRects.push_back(emptyTextRect);
}
if (hasMoreText)
{
m_decorRects.push_back(moreTextRect);
}
// 命中装饰缓存:徽标与图例的像素改由装饰层贴回,跳过重绘(命中矩形已在上方重建)。
if (m_useDecorCache)
@@ -9182,14 +9312,14 @@ namespace jdi
}
// ------------------------------------------------------------------
// 3) 绘制。⚠️ 每块托底都先用**不透明底色铺满外接矩形**再画圆角:
// 圆角之外的那几个像素若仍是波形,会被装饰缓存烘进去,平移贴回时表现为
// "徽标 / 图例边缘残留旧波形"。底色 = 网格底色,铺满外接矩形在视觉上不可见。
// 3) 绘制。托底是**圆角矩形**(与 GX 编号徽标同风格):
// "一个 GX 标签,只是内容换成了图例信息"(用户明确要求)。
// ⚠️ **不再铺任何外扩的不透明直角底色**:装饰位图带 alpha,圆角之外必须保持
// 全透明,贴回时才能透出**当前**波形 —— 否则圆角外会多出一圈"直角白边"。
// ⚠️ 徽标与条目的**悬停 / 激活高亮不在这里**(随鼠标变化,画在贴图之后,
// 见 PaintLegendHighlight)—— 否则会被缓存冻结,悬停时看不到任何反馈。
// ------------------------------------------------------------------
{
UiFillRect(g, OpaqueBaseRect(badgeRect), m_theme->cardBg | 0xFF000000u);
UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg);
UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f);
UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
@@ -9203,10 +9333,11 @@ namespace jdi
continue;
}
const gd::RectF plate = PlateRect(item);
UiFillRect(g, OpaqueBaseRect(plate), m_theme->cardBg | 0xFF000000u);
UiFillRoundRect(g, plate, kLegendPlateRadius * m_scale,
item.selected ? m_theme->selection : m_theme->cardBg);
UiDrawRoundRect(g, plate, kLegendPlateRadius * m_scale,
// 选中态 = 悬停态配色(`accentSoft` 底 + `accent` 描边),
// 与 GX 编号徽标的鼠标悬停效果完全一致。
UiFillRoundRect(g, plate, plateRadius,
item.selected ? m_theme->accentSoft : m_theme->cardBg);
UiDrawRoundRect(g, plate, plateRadius,
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,
gd::RectF(legendBand.X, top, lane.rect.Width, height), m_theme->textMuted,
UiText(g, L"(无可见信号)", m_fonts->uiSmall, emptyTextRect, m_theme->textMuted,
TextAlign::LEFT);
}
else if (static_cast<int>(items.size()) < visibleCount)
else if (hasMoreText)
{
// 图例空间不足时提示剩余信号数量,避免误认为信号丢失。
const std::wstring moreText =
L"+" + std::to_wstring(visibleCount - static_cast<int>(items.size()));
if (contentRight + 20.0f * m_scale <= maxX)
{
UiText(g, moreText, m_fonts->uiSmall,
gd::RectF(contentRight, top, 24.0f * m_scale, height), m_theme->textMuted,
TextAlign::LEFT);
}
UiText(g, moreText, m_fonts->uiSmall, moreTextRect, m_theme->textMuted, TextAlign::LEFT);
}
}