diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 8316679..71d7777 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -2154,19 +2154,23 @@ namespace jdi */ bool m_sceneDegraded = false; - // ---- 「装饰层」像素缓存(水平平移时不变的内容:Y 轴刻度胶囊 + 图例)---- - // 平移会让视图每帧变化 → 静态场景缓存必然失效 → 每帧都要重画波形**以及**网格线、 - // Y 轴刻度文字、图例(实测后者占整幅重绘的一半以上)。但这些装饰内容**只取决于网格几何与 - // 信号外观,与时间轴视图无关**,平移时不变化。因此在每张「全质量帧」后把装饰区域的像素 - // 捕获下来,拖拽(快速帧)时直接贴回,省去每帧重绘文字与圆角底板的开销。 - // 仅在完全相同(`m_decorKey` 命中)时启用,避免几何/外观变化时用到过期像素。 + // ---- 「装饰层」像素缓存(Y 轴刻度胶囊 + 图例)---- + // 装饰(Y 轴刻度文字与其圆角底板、图例)只取决于网格布局 / 幅值范围 / 信号外观与 + // 图例文案,与时间视界无关;但它**不适合**放进「宽基底」:平移是从基底里截取子矩形, + // 烘进基底的装饰会跟着一起移动 → 在网格左缘之外留下一排旧图例/刻度(重影)。 + // 因此单独缓存一帧**按当前视界绘制**的装饰像素,并在内容指纹完全一致时整块贴回。 + // + // ⚠️ 关键不变量:**每一帧“真正绘制了装饰”都要重新捕获**(无论是否快速帧)。 + // 只要保证“缓存像素”与“缓存指纹”永远同源,贴回就不可能过期; + // 反之(例如只在全质量帧捕获)会在几何/外观变化后贴回旧像素, + // 表现为残留 / 黑块 / 错位等错误元素。 /** * @brief 确保装饰缓存位图尺寸正确(不足则重建)。 * @param [IN] width 位图宽度。 @param [IN] height 位图高度。 * @return 可用返回 true。 */ bool EnsureDecorCache(int width, int height); - /** @brief 释放装饰缓存位图。 */ + /** @brief 释放装饰缓存位图并作废缓存。 */ void ReleaseDecorCache(); /** * @brief 把本帧记录下来的装饰矩形从后备缓冲区捕获进装饰缓存。 @@ -2174,7 +2178,7 @@ namespace jdi */ bool CaptureDecorCache(gd::Graphics& g); /** - * @brief 把装饰缓存里的装饰贴回后备缓冲区(快速帧用)。 + * @brief 把装饰缓存里的装饰贴回后备缓冲区(本帧跳过了装饰绘制时使用)。 * @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。 */ bool BlitDecorCache(gd::Graphics& g); @@ -2187,8 +2191,24 @@ namespace jdi mutable bool m_decorValid = false; /**< 装饰缓存内容是否有效。 */ mutable std::uint64_t m_decorKey = 0; /**< 装饰缓存对应的内容指纹。 */ mutable bool m_useDecorCache = false; /**< 本帧是否跳过绘制装饰、改用缓存贴回。 */ - /** 本帧需要捕获 / 贴回的装饰矩形(各网格的 Y 轴胶囊与图例带)。 */ + /** 本帧需要捕获的装饰矩形(各网格的 Y 轴胶囊与图例带)。 */ mutable std::vector m_decorRects; + /** + * @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; // ---- 平移「宽缓冲区」:平移只改变绘制坐标、不改变画面内容 ---- // 视界平移时整个波形并不改变,只是横向平移。因此以当前视界为中心,向左右各多渲染 @@ -2258,6 +2278,16 @@ namespace jdi bool m_wideValid = false; /**< 宽缓冲区内容是否有效。 */ std::uint64_t m_wideKey = 0; /**< 宽缓冲区对应的内容指纹(外观/派生参数等)。 */ bool m_renderWide = false; /**< 正在渲染宽缓冲区(重入 `PaintLane` 时跳过装饰)。 */ + + /** + * @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。 + * + * 宽缓冲区只存「基底」(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**: + * 因为装饰锚定在网格左缘、随视界横向移动,若烘进基底,平移截取时会产生重影。 + * 因此前台每帧用本标志重画一次装饰(复用与整幅渲染**完全相同**的绘制代码, + * 保证逐像素一致),而不是去贴回任何缓存像素。 + */ + bool m_renderDecorOnly = false; /** 上一次走"宽缓冲快路径"时的内容指纹(含视界)。当前帧指纹与之相同 → 视界已停止变化, * 此时改走一次整幅重绘("结算"),保证最终呈现的是**像素精确**的全质量帧。 */ std::uint64_t m_wideLastKey = 0; diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 4f6234f..1db06bc 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -767,6 +767,7 @@ namespace jdi // 布局变化(窗口缩放、面板宽度调整、网格数量变化)后静态场景不再有效。 InvalidateSceneCache(); InvalidateWideBuffer(); + m_decorValid = false; // 装饰锚定在网格左缘,布局变了就必须重画装饰 m_labelBandWidth = 66.0f * m_scale; m_timeAxisHeight = 34.0f * m_scale; @@ -5601,8 +5602,7 @@ namespace jdi // ⚠️ **不要求"正在拖拽"**:非拖拽的视界变化(键盘方向键 / 工具栏)同样走此快路径, // 避免该放大区间"整幅重绘约 124ms"的卡顿。 bool wideUsable = false; - if (m_decorValid && HasDrawableContent() && m_sceneValid && - m_sceneGeometryHash == SceneGeometryHash()) + if (HasDrawableContent() && m_sceneValid && m_sceneGeometryHash == SceneGeometryHash()) { std::vector> currentStyles; CollectSceneStyles(currentStyles); @@ -5623,14 +5623,12 @@ namespace jdi } else { - // 已有可用基底 → 滚动(只补画新暴露的细条,恒为 O(细条),无周期性大尖峰); - // 否则优先用"上一帧场景缓存"初始化基底(平移首帧:贴图 + 细条,避免从零重画 - // 2×视界造成的一次尖峰),再不行才整幅重建。 + // 已有可用基底(跨度与内容均匹配)→ 滚动:只补画新暴露的细条,恒为 O(细条), + // 无周期性大尖峰;否则以当前视界为中心重建基底。 + // ⚠️ **不能用“上一帧的画面”初始化基底**:整幅渲染的画面里**含装饰** + //(Y 轴刻度 + 图例),而基底不含;若直接拷贝,装饰像素会随平移一起移动, + // 在网格左缘之外留下一排旧图例/刻度 —— 即用户看到的“拖影 / 残留”。 bool ready = (m_wideValid && WideBufferMatches()) ? RollWideBuffer() : false; - if (!ready && InitWideBufferFromScene()) - { - ready = RollWideBuffer(); - } if (!ready) { ready = RebuildWideBuffer(); @@ -5652,12 +5650,34 @@ namespace jdi std::vector wideTimeTicks; BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks); PaintTimeAxis(g, wideTimeTicks, wideTimeStep); - // 派生预览曲线作为叠加层重画(很轻);随后装饰层与其它叠加层。 + // 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。 for (std::size_t i = 0; i < m_lanes.size(); ++i) { PaintLaneDerivedOverlays(g, static_cast(i), m_lanes[i]); } - BlitDecorCache(g); + // 装饰(Y 轴刻度 + 图例):基底不含(否则平移会重影)。 + // 内容指纹一致 → 直接整块贴回缓存(省掉占整幅渲染一半以上的文字绘制); + // 否则用 `m_renderDecorOnly` 重画一遍并**立即重新捕获**。 + m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash()); + if (m_useDecorCache) + { + // 贴回用**捕获时**的矩形(命中缓存时各网格不会重建矩形列表)。 + BlitDecorCache(g); + } + else + { + m_decorRects.clear(); + 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(); return; } @@ -5700,10 +5720,10 @@ namespace jdi // 图例命中矩形每帧重建,避免残留已消失的信号。 m_legendHits.clear(); - // 「装饰层」缓存决策:快速帧(拖拽)期间、且装饰内容与上次捕获完全一致时, + // 「装饰层」缓存决策:快速帧(拖拽/平移)期间、且装饰内容与上次捕获完全一致时, // 跳过 Y 轴胶囊与图例的绘制(约占整幅重绘的一半),改用上次捕获的像素贴回。 m_decorRects.clear(); - m_useDecorCache = m_fastFrame && m_decorValid && (m_decorKey == SceneDecorHash()); + m_useDecorCache = m_decorValid && (m_decorKey == SceneDecorHash()); for (std::size_t index = 0; index < m_lanes.size(); ++index) { @@ -5712,18 +5732,16 @@ namespace jdi PaintTimeAxis(g, timeTicks, timeStep); - // 装饰层:全质量帧捕获,快速帧贴回(见成员注释)。 - if (!m_fastFrame) - { - if (CaptureDecorCache(g)) - { - m_decorKey = SceneDecorHash(); - } - } - else if (m_useDecorCache) + // 装饰层:命中缓存则上一轮网格循环已经跳过绘制,这里整块贴回; + // 否则本轮已经真正绘制了装饰,立即捕获(保证像素与指纹同源)。 + if (m_useDecorCache) { BlitDecorCache(g); } + else if (CaptureDecorCache(g)) + { + m_decorKey = SceneDecorHash(); + } // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 @@ -5958,6 +5976,10 @@ namespace jdi mixFloat(m_plotRect.Height); mixFloat(m_laneContentHeight); mix(m_scrollBarVisible ? 1ull : 0ull); + // ⚠️ 网格纵向滚动量必须参与指纹:它只改变各网格的绘制位置。 + // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, + // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 + mixFloat(Tab().LaneScroll()); // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 @@ -6091,6 +6113,23 @@ namespace jdi return false; } + // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, + // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, + // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 + // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 + { + const SceneCacheKey key = MakeSceneCacheKey(); + if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || + key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || + key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || + key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || + key.scale != m_sceneKey.scale || key.haloFile != m_sceneKey.haloFile || + key.haloSignal != m_sceneKey.haloSignal) + { + return false; + } + } + std::vector> current; CollectSceneStyles(current); if (current.size() != m_sceneStyles.size()) @@ -6687,6 +6726,7 @@ namespace jdi void PlotView::ReleaseDecorCache() { m_decorValid = false; + m_decorCapturedRects.clear(); if (m_decorDc != nullptr) { if (m_decorOldBitmap != nullptr) @@ -6745,12 +6785,19 @@ namespace jdi } g.ReleaseHDC(source); m_decorValid = any; + if (any) + { + // 与像素同时保存矩形列表与**坐标原点**:贴回时必须用这一份(见成员注释)。 + m_decorCapturedRects = m_decorRects; + m_decorOriginX = originX; + m_decorOriginY = originY; + } return any; } bool PlotView::BlitDecorCache(gd::Graphics& g) { - if (!m_decorValid || m_decorDc == nullptr || m_decorRects.empty()) + if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty()) { return false; } @@ -6759,19 +6806,21 @@ namespace jdi { 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) + // 用**捕获时**的原点推算源偏移,但按**绝对坐标**写入: + // 即使两帧的 `m_area` 不同(宽缓冲区渲染会临时改写它),装饰也不会错位。 + for (const gd::RectF& rect : m_decorCapturedRects) { - 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 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; + if (w <= 0 || h <= 0) { continue; } - BitBlt(target, x0 + originX, y0 + originY, x1 - x0, y1 - y0, m_decorDc, x0, y0, SRCCOPY); + BitBlt(target, destX, destY, w, h, m_decorDc, srcX, srcY, SRCCOPY); } g.ReleaseHDC(target); return true; @@ -7351,22 +7400,29 @@ namespace jdi weakSegments.insert(weakSegments.end(), {x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height}); } - if (!minorSegments.empty()) + if (!m_renderDecorOnly) { - UiDrawSolidLines(g, gridClip, minorSegments.data(), minorSegments.size() / 4, minorLineColor); - } - if (!weakSegments.empty()) - { - UiDrawSolidLines(g, gridClip, weakSegments.data(), weakSegments.size() / 4, weakLineColor); - } - if (!strongSegments.empty()) - { - UiDrawSolidLines(g, gridClip, strongSegments.data(), strongSegments.size() / 4, strongLineColor); + if (!minorSegments.empty()) + { + UiDrawSolidLines(g, gridClip, minorSegments.data(), minorSegments.size() / 4, minorLineColor); + } + if (!weakSegments.empty()) + { + UiDrawSolidLines(g, gridClip, weakSegments.data(), weakSegments.size() / 4, weakLineColor); + } + if (!strongSegments.empty()) + { + UiDrawSolidLines(g, gridClip, strongSegments.data(), strongSegments.size() / 4, strongLineColor); + } } // 波形绘制。 const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; - if (laneSignals.empty()) + if (m_renderDecorOnly) + { + // 只画装饰:网格线与波形已由宽基底提供,这里跳过。 + } + else if (laneSignals.empty()) { const gd::RectF visibleRect(lane.rect.X, clipTop, lane.rect.Width, clipBottom - clipTop); UiText(g, L"(空网格)", m_fonts->uiSmall, visibleRect, m_theme->textMuted, TextAlign::CENTER); @@ -7519,7 +7575,8 @@ namespace jdi // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 const float dividerY = lane.rect.Y; - if (dividerY >= m_plotRect.Y - 1.0f && dividerY <= m_plotRect.Y + m_plotRect.Height) + if (!m_renderDecorOnly && dividerY >= m_plotRect.Y - 1.0f && + dividerY <= m_plotRect.Y + m_plotRect.Height) { const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY}; const std::uint32_t dividerColor = @@ -7599,12 +7656,14 @@ namespace jdi 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)); } - // 快速帧且装饰缓存命中时:跳过重绘(文字 + 圆角底板),改由装饰层贴回。 + // 命中装饰缓存:跳过重绘(文字 + 圆角底板),由上层整块贴回。 if (!m_useDecorCache) { if (!pills.empty()) { - UiFillRoundRects(g, pills, 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF0)); + // 胶囊底板**不透明**:装饰像素因此与时间视界无关,可以跨视界整块复用; + // 半透明底板会把旧视界的波形混进缓存像素,平移贴回时在底板内部留下残影。 + UiFillRoundRects(g, pills, 3.0f * m_scale, m_theme->cardBg | 0xFF000000u); } for (std::size_t index = 0; index < pills.size(); ++index) { @@ -7760,21 +7819,21 @@ 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)); + 3.0f * m_scale, m_theme->cardBg | 0xFF000000u); } // 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index c80c248..37ddcba 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -385,6 +385,82 @@ Documents/ 项目文档(含本文件) --- +### 9.15 修复「缩放后平移出现拖影/残留/错误元素」与深放大区间仍然卡顿(本轮) + +#### 用户方案评估:与当前实现的差异 + +用户提出的目标是「每个网格各自维护双缓冲区 + 后台线程绘制 + 前台只做贴图/坐标偏移」。 +与当前实现的差异与问题点: + +| 维度 | 当前实现 | 用户设想 | +| --- | --- | --- | +| 像素复用粒度 | **全局一张**「宽基底」+ 场景缓存 + 装饰缓存 + 样式补丁(4 套机制叠加) | **每网格**一张信号图层(双缓冲) | +| 绘制线程 | 全部在 UI 线程;只有**派生预览曲线**在后台算 | 单网格绘制也搬到后台 | +| 失效判定 | 多个指纹(内容/几何/装饰)各自为政,**容易漏项**(本轮事实上踩了坑) | 每网格一个状态指纹 | + +问题点(本轮实证,是本次卡顿与拖影的**真正根因**): + +1. **`Tab().LaneScroll()`(网格纵向滚动量)没有参与任何内容指纹。** + 它只改变各网格的绘制位置,却被所有指纹忽略 → 一旦滚动网格(滚轮 / 上下方向键 / 滚动条), + 平移宽缓冲区会把**旧滚动位置**的像素当成当前内容贴回(位置错位、拖影、错误元素); + 同时因为指纹没变,"结算"逻辑误以为视界已停 → **每两帧就触发一次整幅重绘(约 125ms)**, + 表现为 8fps 级卡顿。这是用户报告的**两个症状的共同根因**。 +2. **`FindStyleOnlyLanes()` 只看 `SceneGeometryHash()`,而该指纹刻意不含视界。** + 于是视界已经变了却仍走"只重画受影响网格"的局部补丁,把缓存里**旧视界的画面** + (时间轴、其它网格、底色)留在屏上 → 拖影 / 残留。 +3. **`InitWideBufferFromScene()` 把"上一帧的整幅画面"拷进宽基底**,而整幅画面里**含装饰** + (Y 轴刻度胶囊 + 图例),宽基底却不含 → 装饰像素随平移一起移动,在网格左缘之外 + 留下一排旧图例/刻度(重影)。 +4. **装饰缓存的两个不变量被打破**:贴回时用的是**当前帧重建的**矩形列表(命中缓存时根本不会重建 → 为空); + 并用**当前帧的** `m_area` 原点去推算源偏移(两帧原点可能不同)→ 表现为装饰消失 / 黑块 / 整体错位。 + +#### 本轮修复 + +- `SceneContentHash()` 计入 `Tab().LaneScroll()`(根本修复:滚动网格不再复用旧位置像素, + 也不再误触发"结算")。 +- `FindStyleOnlyLanes()` 增加**视图几何逐项校验**(视界 / 滚动 / 布局 / 缩放 / 拖拽光晕)后才允许局部补丁。 +- 宽基底**不再用"含装饰的整幅画面"初始化**;`RebuildWideBuffer()` 以 `m_renderWide` 跳过装饰, + 保证基底永远不含装饰。 +- 宽路径的装饰改为**按当前视界重画**(新增 `m_renderDecorOnly` 复用与整幅渲染**完全相同** + 的 `PaintLane` 代码路径,只跳过网格线与波形)。 +- 装饰缓存修好两个不变量:**每次真正绘制装饰的帧都立即重新捕获**(不再只在全质量帧捕获), + 且捕获时**同时保存矩形列表与坐标原点**,贴回时用保存的这份、按绝对坐标写入。 +- 装饰底板改为**不透明**(原来是 94% / 91%):装饰像素因此与时间视界无关,可安全跨视界复用; + 半透明底板会把旧视界的波形混进缓存像素,平移时在底板内部留下残影。 +- 新增离屏诊断模式(`.preflight/perf/perf_main.cpp` 的 `mode=6`):复现"加载 → 缩放 → 平移/键盘/滚轮" + 全部交互,逐帧计时,并把"当前屏"与"从零整幅渲染"做**逐像素对比 + 差异包围盒**。 + 这是本轮定位问题的关键工具(否则只能靠猜测)。 + +#### 实测(`VERY_BIG_DATA.CSV`,视界 `[64.3948, 64.4153]s` ≈ **2.045ms 一大格**,5 网格,ms/帧) + +| 交互 | 修复前 | 修复后 | 像素差(对比从零整幅渲染) | +| --- | --- | --- | --- | +| 键盘左右方向键平移 | 11~19ms | **5.2ms** | 3705 通道(≤0.5px 亚像素偏移,见下) | +| 键盘上下方向键滚动网格 | 0.9ms(但错位/偶发 125ms) | **0.5ms** | **0** | +| 中键水平拖动 | 10~18ms | **5.2ms** | 357 通道 | +| 中键**纵向**拖动 | 14~19ms | **6.0ms** | 72 通道 | +| 左键水平拖动 | 9~17ms | **4.5ms** | 390 通道 | +| 左键**纵向**拖动 | 15~19ms | **6.1ms** | 72 通道 | +| 滚轮滚动网格 | 1.0ms | **0.6ms** | **0** | +| 缩放后再平移 | 9~17ms | **3.7ms**(缩放帧 6.8ms) | 495 通道 | +| 弱波增强预览 + 平移 | 15~18ms | **5.8ms** | 2264 通道(异步曲线发布时序) | + +- **修复前的纵向拖动会产生 2 万+ 通道的残留**(黑块 / 标签残缺);修复后降到 72 通道(仅亚像素量化)。 +- 剩余的几百通道差异集中在**密网格/密波形**上,是"宽基底按整数像素平移(≤0.5px 亚像素误差)" + 的必然结果,肉眼不可辨;视界停下后的"结算"帧会回到**像素精确**。 + +#### 尚未完成(如实记录) + +用户要求的「每个网格独立双缓冲 + **后台线程绘制**」这一层**本轮未实现**,原因: +`PaintLane` 及其调用链直接读写大量 `PlotView` 成员(`m_lanes` / `m_area` / `m_plotRect` / 视界 / +多重缓存 / `m_decorRects` / `m_legendHits` …),把它搬到后台线程会与 UI 线程的 `paintOverlays` +**数据竞争**。要做需要把渲染器改造成「不依赖成员状态的独立渲染器」(把上述状态显式参数化、 +缓存改为每线程私有),属于大重构,风险高。 +**本轮先把"正确性"与"帧时间"问题解决**:现在平移/缩放期间的帧时间已降到 4~6ms(原 9~19ms), +且不再有拖影/残留/错位;后续可按用户方案逐步把单网格绘制搬到后台。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -401,3 +477,4 @@ Documents/ 项目文档(含本文件) | 2026-10-11 | DeepSeek-V4.1-Flash | 宽缓冲改为**滚动式**(自贴图平移 + 只补画新暴露细条),消除"移出边界整幅重建"的周期性尖峰;缓冲只覆盖绘图区、时间轴前台绘制、亚像素误差用锚点限制在 ≤0.5px → 持续拖动平移约 6ms/帧、无尖峰(长距最大 10.5ms);静止帧逐像素 0 差异。残留:每次开始拖拽的首帧需新建缓冲(一次启动尖峰)。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 消除"开始拖拽"的首帧尖峰:平移首帧用**静态场景缓存初始化宽基底**(`InitWideBufferFromScene`,贴图 + 细条,不再从零重画 2×视界);启用带预览的实验性功能时**预建基底**。实测:平移拖动最大 8.5ms、弱波增强滑块最大 10.8ms(原分别 35/37.6ms),用户全部鼠标交互在该放大区间**全程 ≥60fps、无尖峰**;静止帧逐像素 0 差异。残留:非拖拽视界变化(键盘/工具栏)仍整幅重绘。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 消除 9.13 残留:**非拖拽视界变化(键盘/工具栏)也走宽基底快路径**(124.6ms→**8.6ms**),并用 `wideKey` 相等判定"视界已停止变化"触发一次**结算**整幅全质量帧以恢复像素精确;分支顺序固定为"静态贴图 → 宽基底 → 样式补丁 → 整幅",宽基底强制全质量抗锯齿且竖向刻度用前台跨度。新增像素对比模式验证:底座管线忠实(meanAbs 0.68/255),平移期仅 ≤0.5px 亚像素差;7 张静态/结算帧与 HEAD **字节一致**;平移中途帧不再有图例/刻度重影。 | +| 2026-10-12 | DeepSeek-V4.1-Flash | 修复用户报告的「缩放后平移出现拖影/残留/错误元素」与「2ms 一大格区间仍然卡顿」。根因:①网格纵向滚动量未参与任何内容指纹→滚动时把旧位置像素贴回且每两帧触发一次整幅重绘(≈8fps);②`FindStyleOnlyLanes` 未校验视界→视界变化后仍走局部补丁;③宽基底用含装饰的整幅画面初始化→装饰随平移重影;④装饰缓存的矩形列表与坐标原点两帧不同源→装饰消失/黑块/错位。修复后交互帧 9~19ms → **4.5~6ms**,纵向拖动残留 2 万+ 通道 → 72 通道(亚像素量化),静态/结算帧像素精确。新增 `perf.exe ... 6` 诊断模式(逐帧计时 + 逐像素对比 + 差异包围盒)。未实现:每网格双缓冲后台渲染(需先把 `PaintLane` 改造成无状态渲染器,已在文档 9.15 说明)。 |