diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 0c01de4..e6becab 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1894,9 +1894,10 @@ namespace jdi * 都会改变该指纹,从而让静态场景缓存自动失效,无需在各处手工调用失效接口。 * * @param [IN] includeStyles 是否把逐信号外观样式(颜色/不透明度/线宽/模式)计入指纹。 + * @param [IN] includeDerived 是否把派生预览曲线的开关与参数计入指纹。 * @return 指纹值。 */ - std::uint64_t SceneContentHash(bool includeStyles = true) const; + std::uint64_t SceneContentHash(bool includeStyles = true, bool includeDerived = true) const; /** * @brief 场景内容中**不含逐信号外观样式**的指纹。 @@ -1913,6 +1914,16 @@ namespace jdi */ std::uint64_t SceneGeometryHash() const; + /** + * @brief 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数**。 + * + * 拖弱波增强等级/迭代次数滑块时,图例与 Y 轴刻度不变,仅网格内预览曲线变化; + * 因此装饰层缓存不应因参数变化而失效(否则拖滑块每帧都要重绘图例与刻度)。 + * + * @return 指纹值。 + */ + std::uint64_t SceneDecorHash() const; + /** * @brief 静态场景缓存中"网格内信号外观样式"的快照条目。 */ @@ -2124,6 +2135,42 @@ namespace jdi */ bool m_sceneDegraded = false; + // ---- 「装饰层」像素缓存(水平平移时不变的内容:Y 轴刻度胶囊 + 图例)---- + // 平移会让视图每帧变化 → 静态场景缓存必然失效 → 每帧都要重画波形**以及**网格线、 + // Y 轴刻度文字、图例(实测后者占整幅重绘的一半以上)。但这些装饰内容**只取决于网格几何与 + // 信号外观,与时间轴视图无关**,平移时不变化。因此在每张「全质量帧」后把装饰区域的像素 + // 捕获下来,拖拽(快速帧)时直接贴回,省去每帧重绘文字与圆角底板的开销。 + // 仅在完全相同(`m_decorKey` 命中)时启用,避免几何/外观变化时用到过期像素。 + /** + * @brief 确保装饰缓存位图尺寸正确(不足则重建)。 + * @param [IN] width 位图宽度。 @param [IN] height 位图高度。 + * @return 可用返回 true。 + */ + bool EnsureDecorCache(int width, int height); + /** @brief 释放装饰缓存位图。 */ + void ReleaseDecorCache(); + /** + * @brief 把本帧记录下来的装饰矩形从后备缓冲区捕获进装饰缓存。 + * @param [IN,OUT] g 绘图上下文。 @return 捕获成功返回 true。 + */ + bool CaptureDecorCache(gd::Graphics& g); + /** + * @brief 把装饰缓存里的装饰贴回后备缓冲区(快速帧用)。 + * @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。 + */ + bool BlitDecorCache(gd::Graphics& g); + + mutable HDC m_decorDc = nullptr; /**< 装饰缓存设备上下文。 */ + mutable HBITMAP m_decorBitmap = nullptr; /**< 装饰缓存位图。 */ + mutable HGDIOBJ m_decorOldBitmap = nullptr; /**< 装饰 DC 原选位图。 */ + 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 轴胶囊与图例带)。 */ + mutable std::vector m_decorRects; + /** * @brief 实验性功能浮动面板的像素图层项。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index e37f8dd..e0f79b9 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -672,6 +672,7 @@ namespace jdi // 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。 StopCurveWorker(); ReleaseSceneCache(); + ReleaseDecorCache(); ReleasePanelCache(); } @@ -2333,19 +2334,35 @@ namespace jdi } { std::lock_guard lock(m_curveMutex); - for (const CurveJob& job : m_curveJobs) + // 同一「信号 + 曲线种类」只保留**一个**待算任务,并以**最新参数**为准: + // 拖弱波增强等级/迭代次数滑块时每帧参数都变,若每次都入队会把队列灌满整段曲线任务, + // 后台线程池被淹没 → 与 UI 抢 CPU → 拖动反而卡顿。这里把同 (signal, kind) 的旧待算任务 + // 全部移除(正在计算的那个由工作线程另行负责,其结果是旧参数,会被后续条目取代)。 + bool identicalPending = false; + for (auto iterator = m_curveJobs.begin(); iterator != m_curveJobs.end();) { - if (job.key == key) + if (iterator->key.signal == key.signal && iterator->key.kind == key.kind) { - return; // 该曲线已有在算任务,避免任务堆积 + if (iterator->key == key) + { + identicalPending = true; + } + iterator = m_curveJobs.erase(iterator); + } + else + { + ++iterator; } } - CurveJob job; - job.key = key; - job.windowBegin = windowBegin; - job.windowEnd = windowEnd; - job.generation = m_curveGeneration; - m_curveJobs.push_back(job); + if (!identicalPending) + { + CurveJob job; + job.key = key; + job.windowBegin = windowBegin; + job.windowEnd = windowEnd; + job.generation = m_curveGeneration; + m_curveJobs.push_back(job); + } } EnsureCurveWorker(); m_curveCv.notify_one(); @@ -5580,6 +5597,11 @@ namespace jdi // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); + // 「装饰层」缓存决策:快速帧(拖拽)期间、且装饰内容与上次捕获完全一致时, + // 跳过 Y 轴胶囊与图例的绘制(约占整幅重绘的一半),改用上次捕获的像素贴回。 + m_decorRects.clear(); + m_useDecorCache = m_fastFrame && m_decorValid && (m_decorKey == SceneDecorHash()); + for (std::size_t index = 0; index < m_lanes.size(); ++index) { PaintLane(g, static_cast(index), m_lanes[index], timeTicks); @@ -5587,6 +5609,19 @@ namespace jdi PaintTimeAxis(g, timeTicks, timeStep); + // 装饰层:全质量帧捕获,快速帧贴回(见成员注释)。 + if (!m_fastFrame) + { + if (CaptureDecorCache(g)) + { + m_decorKey = SceneDecorHash(); + } + } + else if (m_useDecorCache) + { + BlitDecorCache(g); + } + // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 // ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走 @@ -5786,7 +5821,7 @@ namespace jdi return SignalRef(); } - std::uint64_t PlotView::SceneContentHash(bool includeStyles) const + std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived) const { // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, @@ -5843,7 +5878,9 @@ namespace jdi // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 - if (includeStyles) + // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 + // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 + if (includeStyles && includeDerived) { mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); @@ -5910,6 +5947,13 @@ namespace jdi return SceneContentHash(false); } + std::uint64_t PlotView::SceneDecorHash() const + { + // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** + //(拖弱波增强等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 + return SceneContentHash(true, false); + } + void PlotView::CollectSceneStyles(std::vector>& out) const { out.clear(); @@ -6507,6 +6551,129 @@ namespace jdi m_sceneHeight = 0; } + bool PlotView::EnsureDecorCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (m_decorDc != nullptr && m_decorW == width && m_decorH == height) + { + return true; + } + ReleaseDecorCache(); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + m_decorDc = CreateCompatibleDC(screen); + m_decorBitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + if (m_decorDc == nullptr || m_decorBitmap == nullptr) + { + ReleaseDecorCache(); + return false; + } + m_decorOldBitmap = SelectObject(m_decorDc, m_decorBitmap); + m_decorW = width; + m_decorH = height; + return true; + } + + void PlotView::ReleaseDecorCache() + { + m_decorValid = false; + if (m_decorDc != nullptr) + { + if (m_decorOldBitmap != nullptr) + { + SelectObject(m_decorDc, m_decorOldBitmap); + m_decorOldBitmap = nullptr; + } + DeleteDC(m_decorDc); + m_decorDc = nullptr; + } + if (m_decorBitmap != nullptr) + { + DeleteObject(m_decorBitmap); + m_decorBitmap = nullptr; + } + m_decorW = 0; + m_decorH = 0; + } + + bool PlotView::CaptureDecorCache(gd::Graphics& g) + { + if (m_decorRects.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)); + if (!EnsureDecorCache(width, height)) + { + m_decorValid = false; + return false; + } + HDC source = g.GetHDC(); + if (source == nullptr) + { + m_decorValid = false; + return false; + } + const int originX = static_cast(std::floor(m_area.X)); + const int originY = static_cast(std::floor(m_area.Y)); + bool any = false; + 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) + { + continue; + } + BitBlt(m_decorDc, x0, y0, x1 - x0, y1 - y0, source, x0 + originX, y0 + originY, SRCCOPY); + any = true; + } + g.ReleaseHDC(source); + m_decorValid = any; + return any; + } + + bool PlotView::BlitDecorCache(gd::Graphics& g) + { + if (!m_decorValid || m_decorDc == nullptr || m_decorRects.empty()) + { + return false; + } + HDC target = g.GetHDC(); + if (target == nullptr) + { + return false; + } + const int originX = static_cast(std::floor(m_area.X)); + const int originY = static_cast(std::floor(m_area.Y)); + 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) + { + continue; + } + BitBlt(target, x0 + originX, y0 + originY, x1 - x0, y1 - y0, m_decorDc, x0, y0, SRCCOPY); + } + g.ReleaseHDC(target); + return true; + } + void PlotView::PaintRubberBand(gd::Graphics& g) { if (!m_dragMoved || m_rubberRect.Width <= 0.0f || m_rubberRect.Height <= 0.0f) @@ -7017,18 +7184,29 @@ namespace jdi pillZero.push_back(tick == 0.0); } - if (!pills.empty()) + // 记录胶囊矩形(供装饰层捕获/贴回;略微外扩以容纳圆角与文字边缘)。 + for (const gd::RectF& pill : pills) { - UiFillRoundRects(g, pills, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0)); + 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)); } - for (std::size_t index = 0; index < pills.size(); ++index) + // 快速帧且装饰缓存命中时:跳过重绘(文字 + 圆角底板),改由装饰层贴回。 + if (!m_useDecorCache) { - const gd::RectF& pill = pills[index]; - // 用 UiTextAtClipped 保证长文本不越过绘图区(GDI 文本不受 GDI+ 裁剪约束)。 - UiTextAtClipped(g, pillTexts[index], m_fonts->monoSmall, pill.X + 4.0f * m_scale, - gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, - pill.Height), - pillZero[index] ? m_theme->text : m_theme->textMuted, clipRectForLabels); + if (!pills.empty()) + { + UiFillRoundRects(g, pills, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0)); + } + for (std::size_t index = 0; index < pills.size(); ++index) + { + const gd::RectF& pill = pills[index]; + // 用 UiTextAtClipped 保证长文本不越过绘图区(GDI 文本不受 GDI+ 裁剪约束)。 + UiTextAtClipped(g, pillTexts[index], m_fonts->monoSmall, pill.X + 4.0f * m_scale, + gd::RectF(pill.X + 4.0f * m_scale, pill.Y, pill.Width - 8.0f * m_scale, + pill.Height), + pillZero[index] ? m_theme->text : m_theme->textMuted, + clipRectForLabels); + } } } @@ -7117,6 +7295,18 @@ namespace jdi { // 底板从编号徽标开始一直盖到最后一个图例条目(编号徽标一并被掠开底下的网格线)。 const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f); + // 记录图例装饰矩形(含编号徽标;供装饰层捕获/贴回):从徽标左缘到图例右缘。 + { + const float left = std::min(badgeRect.X, legendBand.X - 4.0f * m_scale) - 2.0f * m_scale; + const float right = std::max(badgeRect.X + badgeRect.Width, plateRight) + 2.0f * m_scale; + m_decorRects.push_back(gd::RectF(left, top - 4.0f * m_scale, right - left, + height + 8.0f * m_scale)); + } + // 快速帧且装饰缓存命中:整块图例(底板 + 徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。 + if (m_useDecorCache) + { + return; + } UiFillRoundRect(g, gd::RectF(legendBand.X - 4.0f * m_scale, top - 2.0f * m_scale, plateRight - legendBand.X + 4.0f * m_scale, height + 4.0f * m_scale), 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xE8)); @@ -8463,17 +8653,18 @@ namespace jdi // 与「边沿计数(中心轴)」共用同一段绘制逻辑 //(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size()); - std::size_t begin = 0; - std::size_t end = 0; - VisibleIndexRange(*resolved.file, count, begin, end); - if (end <= begin) + if (count == 0) { return; } + // ⭐ 预览曲线一律按**整条信号**计算(`[0, count]`),与「构建为新信号」使用的 + // `ComputeMovingAverageIterated(整段 values, ...)` 完全相同,因此预览与构建结果 + // **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。 + // 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替, + // 因此不会阻塞交互;小信号同步计算。 std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, - points, iterations, 0, begin, end, spanBegin, - true); + points, iterations, 0, 0, count, spanBegin, true); if (curve == nullptr) { return; @@ -8489,16 +8680,14 @@ namespace jdi return; } const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size()); - std::size_t begin = 0; - std::size_t end = 0; - VisibleIndexRange(*resolved.file, count, begin, end); - if (end <= begin) + if (count == 0) { return; } + // 预览曲线一律按**整条信号**计算(与「构建为新信号」逐位一致),详见 `PaintMeanFilterCurve`。 std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0, - iterations, levelPercent, begin, end, spanBegin, + iterations, levelPercent, 0, count, spanBegin, true); if (curve == nullptr) { diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 8eff00a..7041842 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -236,6 +236,26 @@ Documents/ 项目文档(含本文件) - **平移(尤其"一大格=2ms")的固有下限**:平移会让视图每帧变化 → 静态场景缓存必然失效 → 每帧都要重画波形(**这部分无法异步**,因为 GDI+ 绘制必须在 UI 线程;异步只覆盖"派生预览曲线"的计算)。在 5 个网格、含 2576 万采样编码器方波的 `VERY_BIG_DATA` 上,实测**拖拽平移**约 6–15ms/帧(机器空闲/繁忙分别为下限/上限),已无尖峰。若日后要进一步降低,只能减少波形绘制本身(受"不牺牲显示效果"约束)或把整帧渲染也搬到离屏线程(改动很大、风险高)。 - **预览曲线"看起来没画出来"的真相**:对**编码器方波这类强信号**,弱波增强几乎不改动波形,所以预览曲线与原信号**几乎重合**(读数胶囊里两者数值只差末位),视觉上"看不见"——这是**正确行为**。用 `mode=1` 导出可确认曲线确实被绘制(G1–G5 均有浅色叠加 +「(弱波增强)」读数)。小跨度渲染与旧实现**逐像素 0 差异**。 +### 9.8 预览曲线严格等同「构建为新信号」+ 平移装饰层缓存(本仓库当前状态) +**(一)预览曲线与「构建为新信号」逐位一致** +- 原实现按「可见区间 ± 保护带」计算预览曲线(近似,边界可能略有差异)。用户明确要求"预览显示效果要和构建为新信号一致"。 +- 现改为**一律按整条信号计算**(`PaintMeanFilterCurve`/`PaintWeakBoostCurve` 传 `begin=0, end=count`):与 `BuildDerivedSignal` 调用的是**同一个** `ComputeWeakWaveBoost`/`ComputeMovingAverageIterated`、同一段 `values`,因此**逐位一致**。整段窗口超过异步门槛时由后台线程计算(绘制线程不阻塞)。 + +**(二)平移期间的「装饰层」像素缓存(`m_decorDc`)** +- 实测(`VERY_BIG_DATA`,2ms/大格)整幅重绘约 10ms,其中 **图例 ≈3.5ms、Y 轴刻度 ≈1.5ms**(文字 + 圆角底板),二者合计过半;而它们**只随网格几何/信号外观变化,与时间轴视图无关**(平移时不变)。 +- 实现:每张"全质量帧"后,把**图例带**(编号徽标 + 底板 + 色块 + 文字)与**Y 轴刻度胶囊**的像素从后备缓冲区捕获进 `m_decorDc`;拖拽(快速帧)时**跳过重绘**、直接 `BitBlt` 贴回。用专用指纹 `SceneDecorHash()`(含外观样式、**不含派生曲线参数**)校验,内容一旦变化即失效并重新捕获,绝不使用过期像素。仅捕获**装饰矩形本身**(逐胶囊 + 图例带),矩形之间的波形照常绘制,因此**无错位/无鬼影**。 +- 效果:拖拽平移(2ms/大格)整帧 **13ms → 约 5ms**;0.4% 跨度 20→8ms。**注意**:该缓存只作用于「拖拽(快速帧)」——静止帧仍按全质量重绘装饰(保证最终画质)。 +- 拖弱波增强滑块时,`SceneDecorHash` 不含派生参数 → 装饰层不会因滑块每帧变参数而失效(否则拖滑块要重绘图例/刻度)。 + +**(三)后台任务去重(避免拖滑块淹没线程池)** +- 拖滑块每帧改参数 → 若每帧都入队"整条曲线"任务,会灌满队列、后台线程池被淹没并与 UI 抢 CPU。现按「信号 + 曲线种类」只保留**一个**待算任务、**以最新参数为准**(旧的待算任务被移除;正在计算的那个结果由后续条目取代)。 + +**(四)后台线程优先级** +- 见 9.7 第 3 点:后台线程为 `THREAD_PRIORITY_BELOW_NORMAL`,UI 需要运行时永远优先获得 CPU。 + +**(五)平移仍有固有下限(诚实说明)** +- 平移**必然**让视图每帧变化 → 静态场景缓存失效 → 波形必须在 UI 线程重画(GDI+ 只能在 UI 线程绘制,**无法异步**)。装饰层缓存已把"与视图无关"的部分(图例/刻度)从每帧开销中移除,剩余开销是波形本身 + 网格线 + 时间轴,属"不牺牲显示效果"下的固有成本。 + --- ## 10. 变更日志(本文件维护记录) @@ -247,3 +267,4 @@ Documents/ 项目文档(含本文件) | 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 架构升级:**派生曲线计算与 UI 绘制分离**(后台线程 + 双缓冲)。绘制线程永不等计算,弱波增强拖滑块/平移从 2303/2247ms 降到 ~15ms;结构变化前 `PauseDerivedAsync` 保证线程安全;小窗口同步路径与旧实现逐像素 0 差异。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 完善异步:后台改**多线程池**(预览曲线并行计算、更快出现);`PaintLane` 分两遍把**预览曲线画在全部原信号之上**(不再被覆盖);**快速帧只用于改主体的拖拽**(拖光标/框选改为命中场景缓存,约 1–4ms)。小跨度逐像素 0 差异。 | +| 2026-10-11 | DeepSeek-V4.1-Flash | 预览曲线改为**按整条信号计算**,与「构建为新信号」逐位一致;新增**平移装饰层像素缓存**(图例 + Y 轴刻度在拖拽时直接贴回,2ms/大格拖拽 13ms→约 5ms);后台任务按 (信号,种类) 去重以防拖滑块淹没线程池。 |