diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 2ec3fb9..224bccd 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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 m_decorRects; /** - * @brief 缓存里那批像素对应的装饰矩形(捕获时与像素同时保存)。 + * @brief 缓存里那批像素对应的装饰矩形(与像素同时生成)。 * * ⚠️ 贴回必须用这一份,**不能**用当前的 `m_decorRects`: * 命中缓存时各网格会跳过绘制、根本不会重建矩形列表(为空), * 若用当前列表就会贴错位置或一个都不贴(表现为装饰消失 / 黑块 / 错位)。 */ mutable std::vector m_decorCapturedRects; - /** - * @brief 捕获缓存像素时使用的绘图区原点(`m_area` 左上角)。 - * - * ⚠️ 贴回时**必须用这一份**推算源偏移:宽缓冲区渲染会临时改写 `m_area`, - * 若捕获与贴回两帧的 `m_area` 不同,直接用当前 `m_area` 作原点会让整块装饰错位。 - */ - mutable int m_decorOriginX = 0; - mutable int m_decorOriginY = 0; // ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ---- // diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index c411086..4e7ec70 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -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(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(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 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(m_lanes.size())) @@ -6778,6 +6830,7 @@ namespace jdi PaintLane(g, laneIndex, m_lanes[static_cast(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(GetProcAddress(gdi, "GdiAlphaBlend")); + } + if (cached == nullptr) + { + HMODULE msimg = LoadLibraryW(L"msimg32.dll"); + if (msimg != nullptr) + { + cached = reinterpret_cast(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(std::ceil(m_area.Width)); - const int height = static_cast(std::ceil(m_area.Height)); + // 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致, + // 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。 + const int width = static_cast(std::ceil(m_area.X + m_area.Width)); + const int height = static_cast(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(width) * static_cast(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 noTicks; + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLane(g, static_cast(index), m_lanes[index], noTicks); + } + m_renderDecorOnly = false; + m_useDecorCache = savedUse; } - const int originX = static_cast(std::floor(m_area.X)); - const int originY = static_cast(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(std::floor(rect.X)) - originX; - const int y0 = static_cast(std::floor(rect.Y)) - originY; - const int x1 = static_cast(std::ceil(rect.X + rect.Width)) - originX; - const int y1 = static_cast(std::ceil(rect.Y + rect.Height)) - originY; - if (x1 <= x0 || y1 <= y0) + const int x0 = std::max(0, static_cast(std::floor(rect.X))); + const int y0 = std::max(0, static_cast(std::floor(rect.Y))); + const int x1 = std::min(width, static_cast(std::ceil(rect.X + rect.Width))); + const int y1 = std::min(height, static_cast(std::ceil(rect.Y + rect.Height))); + for (int y = y0; y < y1; ++y) { - continue; + unsigned char* row = static_cast(m_decorBits) + + (static_cast(y) * static_cast(width)) * 4u; + for (int x = x0; x < x1; ++x) + { + unsigned char* p = row + static_cast(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(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(std::floor(rect.X)); - const int destY = static_cast(std::floor(rect.Y)); - const int srcX = destX - m_decorOriginX; - const int srcY = destY - m_decorOriginY; - const int w = static_cast(std::ceil(rect.X + rect.Width)) - destX; - const int h = static_cast(std::ceil(rect.Y + rect.Height)) - destY; + const int x = static_cast(std::floor(rect.X)); + const int y = static_cast(std::floor(rect.Y)); + const int w = static_cast(std::ceil(rect.X + rect.Width)) - x; + const int h = static_cast(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(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(items.size()) < visibleCount) + else if (hasMoreText) { // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 const std::wstring moreText = L"+" + std::to_wstring(visibleCount - static_cast(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); } } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 82b07a5..ea81d69 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -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`(作用域守卫,**所有退出路径**都会裁剪)。容量超上限的"超大计算" + 改为**一次性分配、用完即释放** —— 这类整段计算本身是秒级的一次性操作(结果会被曲线缓存复用), + 内存不再随线程数放大;中/小规模仍保留容量以维持"多阶弱峰调整 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. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -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-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-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` 零警告。 | --- diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index d10a384..a303f2a 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1014,6 +1014,57 @@ namespace jdi } } // 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 + void TrimReuseBuffer(std::vector& buffer) + { + if (buffer.capacity() > kReuseBufferMaxElements) + { + std::vector empty; + buffer.swap(empty); + } + } + + /** + * @brief 作用域守卫:离开作用域时对 `thread_local` 复用缓冲做一次容量裁剪。 + * + * 用它(而不是在每个 `return` 前手写一遍)可以保证**所有**退出路径都会裁剪, + * 不会因为新增提前返回而漏掉、导致内存再次失控。 + */ + template + class ReuseBufferScope + { + public: + explicit ReuseBufferScope(std::vector& buffer) : m_buffer(buffer) {} + ~ReuseBufferScope() { TrimReuseBuffer(m_buffer); } + ReuseBufferScope(const ReuseBufferScope&) = delete; + ReuseBufferScope& operator=(const ReuseBufferScope&) = delete; + + private: + std::vector& m_buffer; + }; + } // namespace + void ComputeMovingAverageIterated(const std::vector& values, int radius, int iterations, std::vector& out) { @@ -1027,7 +1078,9 @@ namespace jdi // 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。 // 中转缓冲复用容量(线程本地),避免每阶重新分配上千万元素的向量。 + // ⚠️ 用 `ReuseBufferScope` 保证离开作用域时对超大容量做裁剪(见其说明)。 thread_local std::vector bufferBuffer; + ReuseBufferScope reuseScope(bufferBuffer); std::vector& buffer = bufferBuffer; buffer.assign(values.size(), std::numeric_limits::quiet_NaN()); std::vector stage; @@ -1159,6 +1212,7 @@ namespace jdi // 2) 逐个极值点判定它属于哪几类规则点,并算出它自己的目标偏移量。 const std::size_t extremeCount = extremes.size(); thread_local std::vector shiftBuffer; + ReuseBufferScope reuseScope(shiftBuffer); std::vector& shift = shiftBuffer; shift.assign(extremeCount, 0.0); for (std::size_t k = 2; k + 2 < extremeCount; ++k) @@ -1324,9 +1378,14 @@ namespace jdi // 第 1 阶:从原始采样值出发(非有限值保持 NaN)。 // 工作缓冲用线程本地变量复用容量:单条曲线动辄上千万采样,若每次调用都重新分配 // 并零初始化,单条「弱峰调整」的耗时会被明显放大(内存分配 + page fault)。 + // ⚠️ 用 `ReuseBufferScope` 保证**离开作用域时裁剪超大容量**:否则 4 条后台线程 + // 会各自常驻数百 MB(thread_local 只在线程退出时才析构),内存随线程数放大。 thread_local std::vector currentBuffer; thread_local std::vector nextBuffer; thread_local std::vector extremesBuffer; + ReuseBufferScope currentScope(currentBuffer); + ReuseBufferScope nextScope(nextBuffer); + ReuseBufferScope extremesScope(extremesBuffer); std::vector& current = currentBuffer; current.assign(count, std::numeric_limits::quiet_NaN()); for (std::size_t index = 0; index < count; ++index)