diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 8d256ea..10df503 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1751,6 +1751,32 @@ namespace jdi */ void PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const; + /** + * @brief 绘制全部派生预览曲线(可只画悬停高亮的那一条)。 + * + * `PaintLaneDerivedOverlays`(基底/缓存路径,不带高亮)与 + * `PaintDerivedHoverHighlight`(前景高亮)共用本函数,保证两者的几何完全一致。 + * + * ⚠️ **高亮绝不能画进静态场景缓存**(用户 2026-11-02 报告“悬停时预览曲线的高亮丢失”): + * 悬停状态不参与 `SceneContentHash`,命中场景缓存时叠加层根本不会被重画 —— + * 于是“悬停 → 高亮”永远看不到;而一旦参与哈希,又会退化成“每动一下鼠标就整幅重绘”。 + * 正确做法:缓存里只画普通曲线,高亮那一帧在前景(贴图之后)单独叠加。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] hoverOnly true = 只画 `m_meanRowHoverRef` 在 `m_meanRowHoverKind` 面板里的那条曲线 + * (并带高亮);false = 画全部曲线且不带高亮。 + */ + void PaintDerivedCurvesFor(gd::Graphics& g, int laneIndex, const LaneVisual& lane, + bool hoverOnly) const; + + /** + * @brief 在**前景**叠加绘制“鼠标所在面板条目栏”对应的预览曲线高亮。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintDerivedHoverHighlight(gd::Graphics& g) const; + /** * @brief 绘制底部时间轴。 * @param [IN,OUT] g 绘图上下文。 @@ -2332,13 +2358,22 @@ namespace jdi * 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。 * 仅供**预览绘制**传 true;需要精确取值的统计(中心轴边沿计数、取值胶囊) * 传 false,走同步精确计算。 + * @param [IN] computeIfMissing 缓存未命中时是否**进行计算**并发布新条目。 + * + * ⚠️ 光标取值胶囊必须传 false(用户 2026-11-02 反馈“拖光标仍有明显卡顿”): + * 胶囊只要“光标所在的那两个采样”,而缓存键含请求窗口、窗口随光标每帧变化 ⇒ + * 每帧都未命中 ⇒ 每帧同步计算 + 发布。15 信号 × 3 种曲线 = 每帧 45 次发布, + * 条目列表与线性搜索同步膨胀,实测拖动光标单帧 200ms(其中约 150ms 在这里)。 + * 传 false 后:命中就取(绘制路径发布的窗口带保护带、覆盖整个可见区间, + * 正常情况必然命中),未命中则退到同 (信号, 种类) 的最新曲线,再无则本帧不标注。 * @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);暂无可显示曲线时返回 nullptr。 */ const std::vector* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, int points, int iterations, int amountPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, - bool allowAsyncCompute = false) const; + bool allowAsyncCompute = false, + bool computeIfMissing = true) const; /** * @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。 @@ -3926,6 +3961,69 @@ namespace jdi */ mutable std::vector m_decorCapturedRects; + // ---- 「派生预览曲线」像素缓存(均值滤波 / 中心轴边沿计数 / 弱峰调整的叠加曲线)---- + // + // 为什么需要单独一层像素缓存(用户 2026-11-02 反馈“极端工况下所有交互都卡”): + // 每条预览曲线都要按「逐像素列包络带」填充,代价 ∝ **覆盖像素数**。15 个信号 × + // 3 个功能 = 45 条曲线、哪怕单条只要 2ms,一帧也要 90ms —— 而其中**绝大多数帧 + // 画面根本没变**(拖动光标、悬停、静止)。因此与装饰层同一套思路: + // 曲线画进一张带 alpha 的 32bpp DIB,正常帧只做一次 `AlphaBlend`(约 1~2ms)。 + // + // 平移复用(关键):视界**只平移不改跨度**时,画面内容只是横向平移 —— 于是把 DIB + // 的像素整体 memmove 一个 dx,再只重画**新露出的那条竖带**即可(与宽缓冲区 + // 的滚动逻辑同构),因此拖动视界的每帧代价与信号数无关。 + // + // ⚠️ 与装饰层的区别:本层的**内容随视界变化**,因此必须记录渲染时的视界 + // (`m_derivedViewMin/Max`);跨度一变就必须整块重画。 + /** + * @brief 确保派生曲线缓存位图尺寸正确(不足则重建为 32bpp DIB 段)。 + * @param [IN] width 位图宽度。 @param [IN] height 位图高度。 + * @return 可用返回 true。 + */ + bool EnsureDerivedCache(int width, int height); + /** @brief 释放派生曲线缓存位图并作废缓存。 */ + void ReleaseDerivedCache(); + /** + * @brief 把全部派生预览曲线绘制进透明缓存位图。 + * + * `m_derivedStripActive == true` 时只重画 `[m_derivedStripX0, m_derivedStripX1)` 这段竖带 + * (平移后的“新露出”区域),否则整块重画。 + * @return 成功返回 true。 + */ + bool RenderDerivedCache(); + /** + * @brief 把派生曲线缓存按 `AlphaBlend` 贴回后备缓冲区(仅绘图区)。 + * @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。 + */ + bool BlitDerivedCache(gd::Graphics& g); + /** + * @brief 派生曲线缓存的内容指纹(**不含视界**:视界单独比对,用于判断能否滚动复用)。 + * @return 指纹。 + */ + std::uint64_t DerivedOverlayKey() const; + /** + * @brief 按当前视界/内容刷新派生曲线缓存,并把结果贴回后备缓冲区。 + * + * 三种情形:内容与视界都相同 → 只贴图;内容相同且跨度不变(纯平移)→ 滚动 + 补条带; + * 其余 → 整块重画。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintDerivedOverlayLayer(gd::Graphics& g); + + mutable HDC m_derivedDc = nullptr; /**< 派生曲线缓存设备上下文。 */ + mutable HBITMAP m_derivedBitmap = nullptr; /**< 派生曲线缓存位图。 */ + mutable HGDIOBJ m_derivedOldBitmap = nullptr; /**< DC 原选位图。 */ + mutable void* m_derivedBits = nullptr; /**< 位图像素(32bpp DIB 段)。 */ + mutable int m_derivedW = 0; /**< 缓存宽度。 */ + mutable int m_derivedH = 0; /**< 缓存高度。 */ + mutable bool m_derivedValid = false; /**< 缓存内容是否有效。 */ + mutable std::uint64_t m_derivedContentKey = 0; /**< 缓存对应的内容指纹(不含视界)。 */ + mutable double m_derivedViewMin = 0.0; /**< 缓存渲染时的视界下限。 */ + mutable double m_derivedViewMax = 0.0; /**< 缓存渲染时的视界上限。 */ + mutable bool m_derivedStripActive = false; /**< 当前是否只重画一条竖带。 */ + mutable float m_derivedStripX0 = 0.0f; /**< 竖带左边界(客户区像素)。 */ + mutable float m_derivedStripX1 = 0.0f; /**< 竖带右边界(客户区像素)。 */ + // ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ---- // // 平移只改变绘制坐标、不改变画面内容。因此**每个网格各自维护一块离屏位图** diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 0ccb809..87bdf34 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -57,6 +57,32 @@ namespace jdi inline const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */ inline const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */ inline const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */ + /** + * @brief 派生预览曲线走「逐像素列包络带」时,每列抽样几个采样。 + * + * 稠密视界(可见采样数 > 像素列数)下预览曲线若按抽稀折线绘制,相邻顶点的 y + * 会整幅跳变 ⇒ GDI+ 描边代价 ∝ 覆盖像素数,实测单条曲线 76~128ms。 + * 改为逐列记录上下包络后一次 FillPath,单条曲线降到约 2ms(约 60 倍)。 + * 每列抽样 4 个点即可得到饱满的包络(再多只影响饱满度,不影响代价量级)。 + */ + inline const size_t kDerivedCurveSamplesPerColumn = 4; + /** + * @brief 派生预览曲线包络带的**列密度分母**:每 N 个设备像素一列。 + * + * 包络带的填充代价主要取决于**边数**与**覆盖面积**(实测同一形状 1600 列 ≈ 24ms、 + * 400 列 ≈ 6.4ms):预览曲线是半透明叠加层、且本来就是“逐列 min/max”的聚合结果, + * 横向 4px 的分辨率损失在稠密视界下不可辨(尖峰仍不会丢:每列取的是该列的极值)。 + * 稀疏区间仍走逐点折线(见 `PaintDerivedCurve`),不受影响。 + */ + inline const size_t kDerivedCurveColumnDivisor = 4; + /** + * @brief 判定“极密视界”的采样密度阈值(采样/设备像素),超过时包络带列密度降到 + * `kDerivedCurveColumnDivisor`,否则用 2 像素一列。 + * + * 中等密度(1~8 采样/像素)下预览曲线形状本身还有细节,用 2 像素一列即可保住观感; + * 极密视界下每列内部就是一条满幅噪声带,4 像素一列肉眼无差别、代价再降一半。 + */ + inline const size_t kDerivedCurveVeryDenseSamplesPerPixel = 8; /** * @brief 图层**纵向滚动**的量化阈值(位图像素)。 * diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index c019980..e639399 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -2020,7 +2020,8 @@ namespace jdi int points, int iterations, int amountPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, - bool allowAsyncCompute) const + bool allowAsyncCompute, + bool computeIfMissing) const { spanBegin = 0; const std::size_t count = signal.values.size(); @@ -2058,6 +2059,34 @@ namespace jdi return &hit->values; } + // 1b) 「只读」模式(光标取值胶囊):**绝不新建计算任务 / 条目**。 + if (!computeIfMissing) + { + if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) + { + return &stale->values; + } + return nullptr; + } + + // 1b) “只读”模式(光标取值胶囊):**绝不新建计算任务/条目**。 + // + // ⚠️⚠️ 为什么必须这么做(用户 2026-11-02 反馈“拖光标时有明显卡顿”): + // 胶囊只为“光标所在的那两个采样”取值,请求窗口只有几个采样 —— 但缓存键里含窗口, + // 而窗口随光标每帧都在变 ⇒ 每帧都未命中 ⇒ 每帧同步计算并 `PublishCurve` 一次。 + // 15 信号 × 3 种曲线 = 每帧 45 次发布,条目列表不断变长,`Acquire*` 的线性搜索 + // 也随之变慢(实测拖动光标单帧 200ms,其中约 150ms 来自这 45 次重算 + 发布)。 + // 现在:命中就取(绘制路径发布的窗口**带保护带**、覆盖整个可见区间,正常情况一定命中), + // 没命中就退到同 (信号, 种类) 的最新曲线,再没有就本帧不标注。 + if (!computeIfMissing) + { + if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) + { + return &stale->values; + } + return nullptr; + } + // 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 // 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。 // diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 4d8f903..c3d1f48 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -940,6 +940,10 @@ namespace jdi //(用户 2026-10-29 报告的两处现象)。 const int textLayer = UiGetTextLayer(); PaintHoverHighlight(g); + // 实验性功能面板条目栏的悬停 → 对应**预览曲线**的高亮。 + // ⚠️ 必须在贴图之后画(见 `PaintDerivedCurvesFor` 的说明):静态场景缓存里 + // 只有普通曲线,高亮随鼠标变化,烘进缓存就会“悬停看不到 / 移开不消失”。 + PaintDerivedHoverHighlight(g); PaintLegendHighlight(g); PaintRubberBand(g); PaintCursor(g, m_lanes); @@ -1119,11 +1123,8 @@ namespace jdi // 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走), // 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。 PaintLaneDividers(g); - // 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。 - for (std::size_t i = 0; i < m_lanes.size(); ++i) - { - PaintLaneDerivedOverlays(g, static_cast(i), m_lanes[i]); - } + // 派生预览曲线:基底不含它们(走像素缓存层 + 平移滚动,见 `PaintDerivedOverlayLayer`)。 + PaintDerivedOverlayLayer(g); // 装饰(Y 轴刻度 + GX 编号 + 图例):基底不含(否则平移会重影)。 // 内容指纹一致 → 直接按 `AlphaBlend` 贴回缓存(省掉文字绘制); // 否则先把装饰单独绘制进透明缓存位图,再贴回。 @@ -1167,9 +1168,27 @@ namespace jdi // 只有"外观样式"变化(典型场景:拖动自定义颜色滑块实时调色):网格结构、视图几何、 // 波形采样都没变,只是颜色/不透明度/线宽/模式不同 —— 此时只重画受影响的网格, // 再写回缓存。(VERY_BIG_DATA 实测:整幅重绘 30ms+,单网格重画约 4ms。) + // + // ⚠️⚠️ **开启了派生预览曲线时不再走这条补丁路径**(用户 2026-11-02 反馈“改颜色极卡”): + // 派生曲线已经改由独立的像素缓存层绘制(`PaintDerivedOverlayLayer`),而补丁是 + // “贴回场景缓存 → 就地重画受影响网格 → 写回缓存”,它会把缓存里**已经烘进去的曲线像素** + // 覆盖掉(曲线随即消失),并且它内部 `PaintLane` 也不再画曲线。实测 15 信号 × 3 功能时 + // 这条路径每帧还要同步重建一个网格图层(宽基底 = 2 倍宽 × 2 倍高,AA 全质量) + // ⇒ **单帧 2.2 秒**;而直接走整幅重绘只要 ~0.4 秒(其中约 0.06–0.09 秒是曲线重画)。 + // 因此这里要求“没有任何派生功能开启”才允许用补丁路径。 + bool derivedActive = false; + for (const Lane& lane : Tab().Lanes()) + { + if (lane.edgeCenter || lane.meanFilter || lane.weakPeak) + { + derivedActive = true; + break; + } + } { std::vector patchLanes; - if (!m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) && BlitSceneCache(g)) + if (!derivedActive && !m_forceFullRepaint && FindStyleOnlyLanes(patchLanes) && + BlitSceneCache(g)) { // ⚠️ 样式**正在连续变化**(拖透明度 / 粗细 / 拾色滑块):这本身就是一种"交互中"。 // 若不切换快速帧,本帧会用**抗锯齿**重画波形包络带 —— 实测单条网格的包络带 @@ -1179,17 +1198,16 @@ namespace jdi m_fastFrame = true; RepaintSceneCachePatch(g, patchLanes); m_sceneDegraded = true; - // ⚠️ **同步重建受影响的宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”, - // 而每网格图层(宽基底)里仍是**旧样式**的像素。若不在这里重建,紧接着命中 - // “宽基底快路径”的那一帧会把刚画好的新样式**覆盖回旧像素** —— - // 用户报告:拖动线宽滑块期间信号一直按旧的线宽绘制、松手后刷新, - // 再移动鼠标又变回旧线宽(正是这个覆盖造成的)。 - // 按既有预算机制重建(每帧至多 1 个网格,不会在交互帧里做整批重建)。 + // ⚠️ 网格图层(宽基底)里仍是**旧样式**的像素,必须让它失效。 + // 但**不要在本帧同步重建**:宽基底是“2 倍宽 × 2 倍高”的整块重绘 + //(15 条稠密信号实测 2.2 秒),同步重建等于把整帧拖到几秒 + //(用户 2026-11-02 报告“改颜色时卡到无法操作”的直接原因)。 + // 只登记待重建:图层指纹(`LaneLayerHash` 含颜色/线宽/粗细…)会让宽基底快路径 + // 自动跳过它(用底板兜底),等视界真正需要时再由既有预算机制补齐。 for (const int laneIndex : patchLanes) { QueueLaneRebuild(laneIndex); } - RenderPendingLaneRebuilds(1); paintOverlays(); return; } @@ -1225,6 +1243,10 @@ namespace jdi PaintTimeAxis(g, timeTicks, timeStep); + // 派生预览曲线(像素缓存层):必须在装饰层**之前** —— 曲线要垫在 + // Y 轴刻度胶囊 / 图例下面(与改造前的层序一致)。 + PaintDerivedOverlayLayer(g); + // 装饰层:缓存与当前内容同源则直接贴回;否则先把装饰绘制进透明缓存位图再贴回。 if (!m_decorValid || m_decorKey != SceneDecorHash()) { @@ -1957,17 +1979,14 @@ namespace jdi } } - // 第二遍:把所有**派生预览曲线**统一绘制在**本网格全部原信号之上**。 - // 渲染「宽缓冲区」时跳过:宽缓冲只存"基底"(网格/波形,不含派生曲线与装饰), - // 派生曲线改由前台叠加层绘制(见 `PaintLaneDerivedOverlays`), - // 这样拖派生参数滑块时基底不变、可复用宽缓冲像素。 - // ⚠️ `m_renderDecorOnly`(把装饰单独画进透明缓存位图)时**也必须跳过**: - // 派生曲线是"随时间视界变化"的内容,若烘进装饰位图,贴回时会把旧视界的 - // 预览曲线永久留在画面上。 - if (!m_renderWide && !m_renderDecorOnly) - { - PaintLaneDerivedOverlays(g, laneIndex, lane); - } + // 第二遍:派生预览曲线**不再**在这里绘制。 + // + // ⚠️ 旧实现把派生曲线画进网格绘制流程(因此也烘进了静态场景缓存与宽缓冲图层), + // 代价是“每帧重画全部曲线”(15 信号 × 3 功能 = 45 条,每条按像素列包络带填充 + // ≈2ms,单帧 90ms)。现在统一由**派生曲线像素缓存层**绘制: + // `PaintDerivedOverlayLayer` 在两条快路径里贴图,平移时只滚动像素 + 补一条竖带 + //(用户 2026-11-02 要求“视界移动 / 光标动画 / 样式修改都要跟手”)。 + // 因此这里(以及 `m_renderWide` 的图层渲染)都不再画曲线。 // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 g.Restore(waveSave); @@ -2111,7 +2130,28 @@ namespace jdi } } - void PlotView::PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const + void PlotView::PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, + const LaneVisual& lane) const + { + PaintDerivedCurvesFor(g, laneIndex, lane, false); + } + + void PlotView::PaintDerivedHoverHighlight(gd::Graphics& g) const + { + if (m_loadingBusy || m_meanRowHoverLane < 0 || !m_meanRowHoverRef.IsValid()) + { + return; + } + const std::size_t laneIndex = static_cast(m_meanRowHoverLane); + if (laneIndex >= m_lanes.size()) + { + return; + } + PaintDerivedCurvesFor(g, m_meanRowHoverLane, m_lanes[laneIndex], true); + } + + void PlotView::PaintDerivedCurvesFor(gd::Graphics& g, int laneIndex, const LaneVisual& lane, + bool hoverOnly) const { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -2126,6 +2166,10 @@ namespace jdi { return; } + if (hoverOnly && !(centerOn || meanFilterOn || weakPeakOn)) + { + return; + } const int saveState = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); // 面板条目栏的悬停高亮(用户 2026-10-30 要求):鼠标停在哪一行,就把那条**预览曲线** @@ -2133,11 +2177,21 @@ namespace jdi // // ⚠️⚠️ **必须按面板种类分别判定**(用户 2026-11-02 报告):三个面板(均值滤波 / // 中心轴边沿计数 / 弱峰调整)都有“信号条目栏”,同一条信号可能同时在三个面板里各有一行。 - // 旧实现只给 MEAN 分支传 `highlight` ⇒ 只开弱峰 + 中心轴时悬停毫无反应;三个全开时 - // 悬停任何一个面板都只高亮均值滤波那条。现在只高亮“鼠标所在面板”对应的曲线。 + // 只给 MEAN 分支传 `highlight` ⇒ 只开弱峰 + 中心轴时悬停毫无反应;三个全开时 + // 悬停任何一个面板都只高亮均值滤波那条。 + // + // ⚠️⚠️⚠️ **高亮改由前景单独绘制**(用户 2026-11-02 报告“悬停高亮丢失”): + // 悬停状态不参与 `SceneContentHash`,命中静态场景缓存时本函数**根本不会被调用**, + // 因此把高亮画在这里等于“只有整幅重绘的那一帧能看到”。现在: + // * `hoverOnly == false`(基底 / 场景缓存路径)→ 只画普通曲线(进入缓存,永远可见); + // * `hoverOnly == true`(前景叠加)→ 只画悬停那一条并带光晕。 const bool hoverActive = (m_meanRowHoverLane == laneIndex) && m_meanRowHoverRef.IsValid(); for (const SignalRef& ref : tabLane.signals) { + if (hoverOnly && !(hoverActive && (ref == m_meanRowHoverRef))) + { + continue; + } const ResolvedSignal resolved = ResolveSignal(ref); if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) { @@ -2150,7 +2204,7 @@ namespace jdi if (centerItem != nullptr) { const bool highlight = - hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::CENTER); + hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::CENTER); PaintMeanFilterCurve(g, resolved, lane, centerItem->points, centerItem->iterations, highlight); } @@ -2161,7 +2215,7 @@ namespace jdi if (item != nullptr) { const bool highlight = - hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::MEAN); + hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::MEAN); PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, highlight); } } @@ -2177,7 +2231,7 @@ namespace jdi kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item->iterations)); const bool highlight = - hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::PEAK); + hoverOnly && hoveredThisRow && (m_meanRowHoverKind == EdgePanelKind::PEAK); PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, highlight); } } @@ -2930,7 +2984,7 @@ namespace jdi std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor(kind, *resolved.data, points, iterations, amountPercent, begin, - end, spanBegin, true); + end, spanBegin, true, false); if (curvePtr == nullptr) { return; @@ -3288,6 +3342,168 @@ namespace jdi } const std::size_t stride = std::max(1, visibleCount / std::max(1, budget)); + // ⭐⭐ **稠密区间改用「逐像素列包络带」**(2026-11-02 性能修复): + // 旧实现按 `stride` 抽稀成折线后 `DrawLines`。视界覆盖百万级采样时,相邻抽稀点的 + // y 会**整幅跳变**(几乎每个线段都是竖直满幅),而 GDI+ 描边的代价 ∝ **覆盖的像素数**: + // 实测 3200 个顶点的满幅锯齿折线要 **128ms**(抗锯齿)/ **31ms**(无抗锯齿), + // 同样画面的「逐列 min/max 包络带」一次 FillPath 只要 **1.9ms**(约 60 倍), + // 而且**不会漏掉尖峰**(等间隔抽样会整段跳过)。 + // 稀疏区间(可见采样数 ≤ 像素列数)继续走逐点折线:点数本来就少,深放大下也更圆滑。 + // 列密度:中等密度用「每 2 像素一列」,极密(> 8 采样/像素)时降到 4 像素一列。 + // 列数直接决定包络带的边数与填充面积 —— 稠密视界下这是唯一有效的加速手段。 + const float columnDivisor = + (visibleCount > + static_cast(std::max(1.0f, m_plotRect.Width)) * + kDerivedCurveVeryDenseSamplesPerPixel) + ? static_cast(kDerivedCurveColumnDivisor) + : 2.0f; + const int columnCount = static_cast( + std::max(1.0f, std::floor(m_plotRect.Width / columnDivisor))); + const float columnWidth = m_plotRect.Width / static_cast(columnCount); + const bool denseBand = resolved.axis->monotonic && (columnWidth > 0.0f) && + (visibleCount > static_cast(columnCount)); + if (denseBand) + { + // 每列取「等间隔抽样」的若干个采样记录上下包络:抽样密度只影响包络的饱满程度, + // 却把逐点扫描压到「像素列数 × 少量采样」量级。 + const std::size_t columnStride = std::max( + 1, visibleCount / + (static_cast(columnCount) * kDerivedCurveSamplesPerColumn)); + std::vector columnsData; + std::vector segmentBreaks; + columnsData.reserve(static_cast(columnCount) + 1); + bool inSegment = false; + int lastColumn = -1; + for (std::size_t index = begin; index < end; index += columnStride) + { + // 水平镜像 = 数据反序:遍历下标映射到源下标后再取曲线值。 + const std::size_t sourceIndex = SourceIndexFor(*resolved.style, count, index); + if (sourceIndex < curveBegin || (sourceIndex - curveBegin) >= curve.size()) + { + inSegment = false; + continue; + } + const float sampled = curve[sourceIndex - curveBegin]; + if (!std::isfinite(sampled)) + { + inSegment = false; + continue; + } + float y = + ValueToY(ApplyDrawTransform(*resolved.style, static_cast(sampled)), lane); + y = static_cast(ClampDouble(y, limitTop, limitBottom)); + // 时间偏移:该采样画在 `t + offsetTime` 处。 + const float x = TimeToX(time[index] + resolved.style->offsetTime); + int column = static_cast(std::floor((x - m_plotRect.X) / columnWidth)); + column = std::max(0, std::min(columnCount - 1, column)); + if (!inSegment) + { + segmentBreaks.push_back(columnsData.size()); + BandColumn fresh; + fresh.x = x; + fresh.top = y; + fresh.bottom = y; + columnsData.push_back(fresh); + lastColumn = column; + inSegment = true; + continue; + } + if (column == lastColumn) + { + BandColumn& back = columnsData.back(); + back.top = std::min(back.top, y); + back.bottom = std::max(back.bottom, y); + continue; + } + BandColumn next; + next.x = x; + next.top = y; + next.bottom = y; + columnsData.push_back(next); + lastColumn = column; + } + if (columnsData.empty()) + { + return; + } + // 线宽补偿与波形基底同一套(`EmitEnvelopeBand` 做纵向外扩 + 横向膨胀), + // 因此“把线宽拖大”时预览曲线的竖直沿同样会变粗。 + const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f; + const auto buildBand = [&](float halfWidth, std::vector& outBand, + std::vector& outBreaks, bool& outSpiky) + { + outBand.clear(); + outBreaks.clear(); + outSpiky = false; + for (std::size_t s = 0; s < segmentBreaks.size(); ++s) + { + const std::size_t start = segmentBreaks[s]; + const std::size_t stop = + (s + 1 < segmentBreaks.size()) ? segmentBreaks[s + 1] : columnsData.size(); + if (stop < start + 2) + { + continue; + } + std::vector part(columnsData.begin() + static_cast(start), + columnsData.begin() + static_cast(stop)); + EmitEnvelopeBand(part, outBand, outBreaks, outSpiky, lane.rect.Height, halfWidth); + } + }; + const auto fillBand = [&](const std::vector& bandPoints, + const std::vector& bandBreaks, std::uint32_t color) + { + gd::GraphicsPath path; + // 包络带会自交(相邻列的上下包络交叉),必须用缠绕规则,否则交叠处被挖空。 + path.SetFillMode(gd::FillModeWinding); + for (std::size_t k = 0; k < bandBreaks.size(); ++k) + { + const std::size_t start = bandBreaks[k]; + const std::size_t stop = + (k + 1 < bandBreaks.size()) ? bandBreaks[k + 1] : bandPoints.size(); + const std::size_t total = stop - start; + if (total < 4 || (total % 2) != 0) + { + continue; + } + const std::size_t half = total / 2; + path.StartFigure(); + path.AddLines(bandPoints.data() + start, static_cast(half)); + path.AddLines(bandPoints.data() + start + half, static_cast(half)); + path.CloseFigure(); + } + if (path.GetPointCount() > 0) + { + gd::SolidBrush brush(ToGdiColor(color)); + g.FillPath(&brush, &path); + } + }; + std::vector bandPoints; + std::vector bandBreaks; + bool spiky = false; + buildBand(strokeHalf, bandPoints, bandBreaks, spiky); + if (bandBreaks.empty()) + { + return; + } + const int savedSmoothing = g.GetSmoothingMode(); + if (spiky || m_fastFrame) + { + g.SetSmoothingMode(gd::SmoothingModeNone); + } + // 悬停高亮:先用**更粗的同一几何**铺一层半透明光晕(垫在曲线下面),再画曲线本身。 + if (highlight) + { + std::vector haloSpan; + std::vector haloBreaks; + bool haloSpiky = false; + buildBand(strokeHalf + 2.5f * m_scale, haloSpan, haloBreaks, haloSpiky); + fillBand(haloSpan, haloBreaks, WithAlpha(derived, 0x55u)); + } + fillBand(bandPoints, bandBreaks, derived); + g.SetSmoothingMode(static_cast(savedSmoothing)); + return; + } + std::vector points; std::vector breaks; points.reserve(std::min(visibleCount, budget) + 1); diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp index 040edec..c23285b 100644 --- a/Components/PlotView/Src/PlotViewRaster.cpp +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -744,6 +744,306 @@ namespace jdi return any; } + // --------------------------------------------------------------------------- + // 「派生预览曲线」像素缓存 + // --------------------------------------------------------------------------- + + std::uint64_t PlotView::DerivedOverlayKey() const + { + // 内容指纹 = 场景内容(含外观样式 + 派生参数 + 网格幅值范围 + 网格滚动量 + 绘图区几何)。 + // ⚠️ 视界**不在这里**:平移时要靠它判断“能否只滚动像素”,所以视界单独比对。 + std::uint64_t hash = SceneContentHash(true, true, true, true); + // 后台算好的派生曲线到达时,曲线像素必须更新(`m_curveSerial` 随每次发布自增)。 + { + std::lock_guard lock(m_curveMutex); + hash ^= m_curveSerial * 1099511628211ull; + } + // 高亮状态**不参与**:高亮由前景单独绘制(见 `PaintDerivedHoverHighlight`)。 + return hash; + } + + bool PlotView::EnsureDerivedCache(int width, int height) + { + if (width <= 0 || height <= 0) + { + return false; + } + if (m_derivedDc != nullptr && m_derivedBits != nullptr && m_derivedW == width && + m_derivedH == height) + { + return true; + } + ReleaseDerivedCache(); + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + // 与装饰层同理:必须是 32bpp DIB 段(带真 alpha + 像素可直接 memmove 滚动)。 + 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 (dib == nullptr || bits == nullptr) + { + if (dib != nullptr) + { + DeleteObject(dib); + } + return false; + } + HDC dc = CreateCompatibleDC(nullptr); + if (dc == nullptr) + { + DeleteObject(dib); + return false; + } + m_derivedOldBitmap = SelectObject(dc, dib); + m_derivedDc = dc; + m_derivedBitmap = dib; + m_derivedBits = bits; + m_derivedW = width; + m_derivedH = height; + m_derivedValid = false; + return true; + } + + void PlotView::ReleaseDerivedCache() + { + m_derivedValid = false; + if (m_derivedDc != nullptr) + { + if (m_derivedOldBitmap != nullptr) + { + SelectObject(m_derivedDc, m_derivedOldBitmap); + m_derivedOldBitmap = nullptr; + } + DeleteDC(m_derivedDc); + m_derivedDc = nullptr; + } + if (m_derivedBitmap != nullptr) + { + DeleteObject(m_derivedBitmap); + m_derivedBitmap = nullptr; + } + m_derivedBits = nullptr; + m_derivedW = 0; + m_derivedH = 0; + } + + bool PlotView::RenderDerivedCache() + { + m_derivedValid = false; + if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty()) + { + return false; + } + 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 (!EnsureDerivedCache(width, height)) + { + return false; + } + const std::size_t bytes = static_cast(width) * static_cast(height) * 4u; + if (!m_derivedStripActive) + { + // 整块重画:先把全部像素清成透明。 + std::memset(m_derivedBits, 0, bytes); + } + else + { + // 补条带:只把这条竖带清成透明(其余像素是滚动过来的有效内容)。 + const int x0 = std::max(0, static_cast(std::floor(m_derivedStripX0))); + const int x1 = std::min(width, static_cast(std::ceil(m_derivedStripX1))); + for (int y = 0; y < height; ++y) + { + unsigned char* row = static_cast(m_derivedBits) + + (static_cast(y) * static_cast(width)) * 4u; + if (x1 > x0) + { + std::memset(row + static_cast(x0) * 4u, 0, + static_cast(x1 - x0) * 4u); + } + } + } + { + gd::Graphics g(m_derivedDc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + if (m_derivedStripActive) + { + // ⚠️ 绘制期的横向裁剪(`PaintDerivedCurvesFor` 只做 `intersect`,不会覆盖这一层)。 + g.SetClip(gd::RectF(m_derivedStripX0, 0.0f, std::max(0.0f, m_derivedStripX1 - m_derivedStripX0), + static_cast(height)), + gd::CombineModeIntersect); + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintDerivedCurvesFor(g, static_cast(index), m_lanes[index], false); + } + } + m_derivedContentKey = DerivedOverlayKey(); + m_derivedViewMin = Tab().ViewMin(); + m_derivedViewMax = Tab().ViewMax(); + m_derivedValid = true; + return true; + } + + bool PlotView::BlitDerivedCache(gd::Graphics& g) + { + if (!m_derivedValid || m_derivedDc == nullptr || m_plotRect.Width < 1.0f) + { + return false; + } + void* proc = ResolveAlphaBlend(); + if (proc == nullptr) + { + return false; + } + 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; + } + // 缓存位图原点 = 客户区 (0,0):源与目标坐标相同。只贴绘图区(曲线被夹在网格内)。 + const int x = std::max(0, static_cast(std::floor(m_plotRect.X))); + const int y = std::max(0, static_cast(std::floor(m_plotRect.Y))); + const int w = std::min(m_derivedW - x, static_cast(std::ceil(m_plotRect.Width))); + const int h = std::min(m_derivedH - y, static_cast(std::ceil(m_plotRect.Height))); + if (w <= 0 || h <= 0) + { + g.ReleaseHDC(target); + return false; + } + BLENDFUNCTION fn = {}; + fn.BlendOp = AC_SRC_OVER; + fn.SourceConstantAlpha = 255; + fn.AlphaFormat = AC_SRC_ALPHA; + const BOOL done = blend(target, x, y, w, h, m_derivedDc, x, y, w, h, fn); + g.ReleaseHDC(target); + return done != FALSE; + } + + void PlotView::PaintDerivedOverlayLayer(gd::Graphics& g) + { + if (m_loadingBusy || m_lanes.empty()) + { + return; // 加载遮罩期间不花时间在派生曲线上(本来也看不见) + } + // 没有任何派生功能开启时直接跳过(并释放缓存,避免白占内存)。 + bool anyFeature = false; + for (const Lane& lane : Tab().Lanes()) + { + if (lane.edgeCenter || lane.meanFilter || lane.weakPeak) + { + anyFeature = true; + break; + } + } + if (!anyFeature) + { + m_derivedValid = false; + return; + } + + const std::uint64_t contentKey = DerivedOverlayKey(); + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; + bool needRender = true; + bool rolled = false; + m_derivedStripActive = false; + if (m_derivedValid && m_derivedContentKey == contentKey && span > 0.0) + { + const double cachedSpan = m_derivedViewMax - m_derivedViewMin; + const double sameSpanTolerance = std::max(1.0e-12, std::fabs(span) * 1.0e-9); + if (std::fabs(cachedSpan - span) <= sameSpanTolerance && + m_derivedViewMin == viewMin && m_derivedViewMax == viewMax) + { + needRender = false; // 完全同源:只贴图 + } + else if (std::fabs(cachedSpan - span) <= sameSpanTolerance && m_plotRect.Width > 1.0f) + { + // 纯平移:像素整体横移,只重画新露出的竖带(与宽缓冲区的滚动同构)。 + const double pxPerTime = static_cast(m_plotRect.Width) / span; + const int shift = static_cast( + std::lround((m_derivedViewMin - viewMin) * pxPerTime)); + const int width = m_derivedW; + if (shift != 0 && std::abs(shift) < width) + { + const std::size_t rowBytes = static_cast(width) * 4u; + const std::size_t moveBytes = + rowBytes - static_cast(std::abs(shift)) * 4u; + unsigned char* bits = static_cast(m_derivedBits); + if (bits != nullptr && moveBytes > 0) + { + for (int y = 0; y < m_derivedH; ++y) + { + unsigned char* row = bits + static_cast(y) * rowBytes; + if (shift > 0) + { + // 内容右移:从本行右端往前搬(`memmove` 自带重叠安全)。 + std::memmove(row + static_cast(shift) * 4u, row, moveBytes); + } + else + { + // 内容左移:源在本行右侧 —— 目标**不能**越过行首(否则越界)。 + std::memmove(row, row + static_cast(-shift) * 4u, moveBytes); + } + } + } + const float plotLeft = m_plotRect.X; + const float plotRight = m_plotRect.X + m_plotRect.Width; + if (shift > 0) + { + m_derivedStripX0 = plotLeft; + m_derivedStripX1 = std::min(plotRight, plotLeft + static_cast(shift) + 1.0f); + } + else + { + m_derivedStripX0 = std::max(plotLeft, plotRight + static_cast(shift) - 1.0f); + m_derivedStripX1 = plotRight; + } + if (m_derivedStripX1 > m_derivedStripX0 + 0.5f) + { + m_derivedStripActive = true; + rolled = true; + } + } + needRender = true; + } + } + if (needRender) + { + if (!RenderDerivedCache()) + { + m_derivedStripActive = false; + return; + } + } + m_derivedStripActive = false; + if (!BlitDerivedCache(g)) + { + // 无法 AlphaBlend:退回逐条曲线直接绘制(正确性优先,代价高但极少发生)。 + if (!rolled) + { + m_derivedValid = false; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLaneDerivedOverlays(g, static_cast(index), m_lanes[index]); + } + } + } + bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height) { if (width <= 0 || height <= 0) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 556ab07..5b0b9b4 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -9403,3 +9403,98 @@ make release / make debug 零 warning;Release 808,960 B 否则会因"扫描早已完成"而假失败(真实场景的更新路径是同一条)。 * ⚠️ PowerShell 5.1 按 ANSI 读 `.ps1`:脚本里**不能出现中文**(连正则里带中文都会解析失败), 要把中文内容放进 UTF-8 数据文件再读取。 + +--- + +## 2026-11-02 第二十三轮(本机):悬停高亮回归修复 + 「极端工况」绘制管线性能改造 + +### 1. ⚠️ 悬停高亮"丢失"的根因与修复 + +现象(用户报):鼠标停在**带预览曲线的实验性功能面板的信号名条目栏**上,对应的预览曲线不再高亮。 + +根因:高亮被画在 `PaintLaneDerivedOverlays`(预览曲线绘制)内部,而这条绘制路径在 +**命中静态场景缓存时根本不会被调用**(`SceneCacheMatches() && BlitSceneCache(g)` 直接 +`paintOverlays(); return;`)。悬停态又不参与 `SceneContentHash`(参与的话每动一下鼠标就整幅重绘) +⇒ 缓存命中帧永远看不到高亮。旧探针之所以"能过",是因为它每帧都整幅重绘。 + +修复(结构性,不是补丁):**高亮一律画在贴图之后的前景**(与图例高亮同一套思路)。 +* `PaintDerivedCurvesFor(g, lane, hoverOnly)`:`hoverOnly == false`(基底/缓存路径)只画普通曲线、 + 不带高亮;`hoverOnly == true` 只画悬停那一条并带光晕。 +* 新函数 `PaintDerivedHoverHighlight(g)`,在 `paintOverlays()` 里紧跟 `PaintHoverHighlight` + 调用(在面板之下、光标之上)。 +* 高亮仍**必须按面板种类判定**(MEAN / CENTER / PEAK 各自只看自己面板的悬停行)。 + +### 2. ⭐⭐ 极端工况的性能改造(3 次 VERY_BIG_DATA + 全部信号并一条网格 + 全部实验性功能) + +改造前实测(离屏探针 `p26f`,15 信号 × 3 功能 = 45 条预览曲线,1600×900): + +| 场景 | 改造前 | 改造后 | +| --- | --- | --- | +| 中键拖动视界 | ~264 ms | **5.9 ms** | +| 双光标拖动 | ~187 ms | **20.9 ms** | +| 弱峰滑块拖动 | ~555 ms | **89 ms** | +| 改信号颜色 | ~2661 ms | **200 ms** | +| 悬停面板行 | ~225 ms | **38 ms** | +| 深放大后拖动视界 | ~207 ms | **23.8 ms** | +| 静止帧 | 7.8 ms | **0.6 ms** | + +#### 2.1 预览曲线改「逐像素列包络带」(单条 15ms → 1.7ms) +旧实现把派生曲线用 `stride` 抽稀成折线后 `DrawLines`。视界覆盖百万采样时相邻抽稀点 y **整幅跳变** +(几乎每段都是竖直满幅),而 GDI+ 描边代价 ∝ **覆盖像素数**:实测 3200 顶点满幅锯齿折线 +**128ms(抗锯齿)/ 31ms(无抗锯齿)**,同样画面的逐列 min/max 包络带一次 `FillPath` 只要 **1.9ms**。 +现在稠密区间(可见采样数 > 像素列数)改用与波形基底同一套 `EmitEnvelopeBand`(逐列极值 + +线宽补偿),稀疏区间仍走逐点折线。附带好处:**不再漏掉尖峰**(等间隔抽样会整段跳过)。 +列密度:中等密度 2px/列,极密(> 8 采样/px)4px/列(`kDerivedCurveColumnDivisor` / +`kDerivedCurveVeryDenseSamplesPerPixel`)—— 填充代价主要取决于边数与面积。 + +#### 2.2 ⭐ 预览曲线改「像素缓存层 + 平移滚动」(这是把拖动降到 6ms 的关键) +`PaintDerivedOverlayLayer`:45 条曲线画进一张带 alpha 的 32bpp DIB(与装饰层同一套配方), +正常帧只做一次 `AlphaBlend`(约 1~2ms)。三种情形: +* 内容与视界都相同 → 只贴图(静止 / 悬停 / 光标动画的帧全部走这条); +* 内容相同、跨度不变(纯平移)→ **像素整行 memmove 一个 dx + 只重画新露出的竖带** + (与宽缓冲区滚动同构)⇒ 拖动视界每帧代价与信号数无关; +* 其余(缩放 / 参数 / 样式变化)→ 整块重画,并记下渲染时的视界。 +内容指纹 `DerivedOverlayKey()` = `SceneContentHash(true,true,true,true)` ⊕ `m_curveSerial` +(后台曲线到达时必须刷新);高亮状态**不进**指纹(前景绘制)。 +⚠️ 滚动时 `memmove` 的方向必须分开写:内容左移(`shift<0`)时目标不能越过行首,否则越界崩溃 +(本轮真实踩到)。 + +#### 2.3 ⚠️ 光标取值胶囊不再"每帧重算并发布 45 条曲线" +`CurveSpanFor` 新增 `computeIfMissing`。胶囊只要光标所在那两个采样,而缓存键含请求窗口、 +窗口随光标每帧变化 ⇒ 每帧 45 次未命中 ⇒ 45 次同步计算 + `PublishCurve`(条目列表与线性搜索 +同步膨胀)。实测占拖动光标单帧 **约 150ms**。现在胶囊只取"已覆盖"的曲线(绘制路径发布的窗口 +带保护带、覆盖整个可见区间),未命中退到同 (信号, 种类) 的最新曲线,再无则本帧不标注。 + +#### 2.4 ⚠️⚠️ 样式补丁路径不再同步重建网格图层("改颜色卡到无法操作"的根因) +样式补丁(拖颜色/粗细/透明度)每帧 `QueueLaneRebuild` + `RenderPendingLaneRebuilds(1)`。 +一个网格图层是 **2 倍宽 × 2 倍高**的整块重绘,15 条稠密信号实测 **2.2 秒** +(整幅重绘只要 ~0.4 秒)。现在: +* **不再同步重建**(只登记待重建;`LaneLayerHash` 含颜色/线宽,宽基底快路径会自动跳过它); +* 开启了派生功能时**直接不走补丁路径**(补丁会把缓存里已烘进去的曲线像素覆盖掉), + 改为整幅重绘 —— 实测 2.2s → 0.2s。 + +### 3. 验证(离屏探针,`SW_HIDE` + 合成消息,不占用鼠标/键盘/屏幕) + +``` +.preflight/p27(新增,8/8) + 静态帧命中场景缓存;预览曲线在静态帧上可见;**悬停面板行在缓存命中帧上仍高亮** + 平移后的叠加层与"完全重画"的参考帧逐像素一致(diff=0) + 缩放后 / 改颜色后同样逐像素一致(diff=0,说明滚动/重建都正确) + 无功能时缓存被释放、重新开启后重建 +.preflight/p20 9/9 · p21 9/9 · p22 14/14 · p23 8/8 · p25 12/12 +make release / make debug 零 warning;Release 814,080 B +``` + +### 4. 踩坑(重要) + +* ⚠️⚠️ **离屏探针必须把画面画进 DIB 段的 HDC**(`CreateDIBSection` + `CreateCompatibleDC`), + 与真实后备缓冲区一致。画进 `gd::Bitmap` 时每次 `Graphics::GetHDC()`(`UiText` 每次都会调) + 都要锁/刷新软件表面 ⇒ 实测**单次文字 2.5ms**,会把这个成本错误地算到"文字/光标"头上 + (同一份代码换 DIB 后单次文字 0.04ms)。换成 DIB 后 `paintOverlays` 里 30 个取值标注 + 从 200ms 降到 2.7ms —— **之前所有"光标拖动很慢"的结论都是探针假象**。 +* ⚠️ 探针里 `OnMouseMove(cursorX, cy)` 很容易命中信号曲线而变成 **MOVE_SIGNAL** 拖拽 + (该模式每帧让场景缓存失效);测光标拖动必须先断言 `m_dragMode == CURSOR_MOVE`。 +* ⚠️ 场景缓存命中帧不会再走预览曲线绘制路径 —— 任何"随鼠标变化"的叠加内容都必须画在 + `paintOverlays()` 里,否则只会在整幅重绘的那一帧出现。 +* ⚠️ PowerShell 5.1 的 `-replace`/多行字符串在含中文/引号时会把命令搞坏;改文件内容优先 + 用编辑工具或 `.ps1` + `[System.IO.File]::WriteAllText`。