修复缩放后平移的拖影/残留/错误元素与深放大区间卡顿:网格纵向滚动量纳入内容指纹、样式补丁增加视界校验、宽基底不再用含装饰的画面初始化、装饰缓存改为每帧重捕获并记录矩形与坐标原点、装饰底板改不透明以支持跨视界复用;交互帧 9~19ms 降至 4.5~6ms,纵向拖动残留 2 万+ 通道降至 72 通道,静止/结算帧像素精确;新增离屏诊断模式 6(逐帧计时 + 逐像素对比)

This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-12 02:40:56 +08:00
1 parent 1c9c43233d
commit e22749b57a
3 files changed
+224 -58

No files matched your search

+39 -9
View File
@@ -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<gd::RectF> m_decorRects;
/**
* @brief 缓存里那批像素对应的装饰矩形(捕获时与像素同时保存)。
*
* ⚠️ 贴回必须用这一份,**不能**用当前的 `m_decorRects`:
* 命中缓存时各网格会跳过绘制、根本不会重建矩形列表(为空),
* 若用当前列表就会贴错位置或一个都不贴(表现为装饰消失 / 黑块 / 错位)。
*/
mutable std::vector<gd::RectF> m_decorCapturedRects;
/**
* @brief 捕获缓存像素时使用的绘图区原点(`m_area` 左上角)。
*
* ⚠️ 贴回时**必须用这一份**推算源偏移:宽缓冲区渲染会临时改写 `m_area`,
* 若捕获与贴回两帧的 `m_area` 不同,直接用当前 `m_area` 作原点会让整块装饰错位。
*/
mutable int m_decorOriginX = 0;
mutable int m_decorOriginY = 0;
// ---- 平移「宽缓冲区」:平移只改变绘制坐标、不改变画面内容 ----
// 视界平移时整个波形并不改变,只是横向平移。因此以当前视界为中心,向左右各多渲染
@@ -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;
+108 -49
View File
@@ -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<std::vector<SceneStyleEntry>> 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<double> 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<int>(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<int>(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<std::vector<SceneStyleEntry>> 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<int>(std::floor(m_area.X));
const int originY = static_cast<int>(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<int>(std::floor(rect.X)) - originX;
const int y0 = static_cast<int>(std::floor(rect.Y)) - originY;
const int x1 = static_cast<int>(std::ceil(rect.X + rect.Width)) - originX;
const int y1 = static_cast<int>(std::ceil(rect.Y + rect.Height)) - originY;
if (x1 <= x0 || y1 <= y0)
const int destX = static_cast<int>(std::floor(rect.X));
const int destY = static_cast<int>(std::floor(rect.Y));
const int srcX = destX - m_decorOriginX;
const int srcY = destY - m_decorOriginY;
const int w = static_cast<int>(std::ceil(rect.X + rect.Width)) - destX;
const int h = static_cast<int>(std::ceil(rect.Y + rect.Height)) - destY;
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<SignalRef>& laneSignals = m_tab->Lanes()[static_cast<std::size_t>(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 徽标使用同一套配色与圆角)。
+77
View File
@@ -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 说明)。 |