交互流畅性三项修复:光标不再跟随视界 / 缩放首帧不再卡顿 / 超大信号拖预览不再拖慢前台

一、光标不再跟随视界(用户要求)
- 删除 ClampCursorsAfterPan() 及其在平移路径的三处调用(中键/左键拖拽平移、
  PanTimeByFraction 方向键、OnMouseWheel(shift=true))。ClampCursorToView() 保留,
  只在真正拖拽光标时使用。
- 现行为:平移/缩放视界时光标的时间位置完全不变(可滑出可视区,读数贴在网格边缘显示)。
- 验证:平移 20 帧把视界移到 [257.6, 386.4]s(光标时间 38.643036 远在视口外)后
  CursorTime 完全不变;Shift+滚轮同样不变。

二、缩放首帧不再卡顿(证实"必要像素量级飙升导致首帧卡住"的猜测)
- 量化:每次缩放变化后首帧 70~80ms(宽跨度)/ 26~40ms(第二帧),第三帧起 0.5ms。
- 根因:跨度变化时 Paint 里 if (!LayersStructurallyReady()) RebuildWideBuffer();
  会同步重建全部 5 个网格图层(70~80ms),随后一帧再做一次整幅"结算"(40ms),
  单次缩放合计约 110ms 卡顿。
- 修法(用户方案:先画了再说,不卡前台):
  1) BlitWideBuffer 对"跨度过期"的图层不再跳过(跳过会让网格在重画完成前空白),
     改为把旧图层覆盖的时间段重新映射到当前视界,用一次 StretchBlt(HALFTONE)
     拉出低分辨率但立即可用的占位画面;
  2) Paint 不再同步整批重建:只有"图层数量与网格数量不符"(首次绘制/增删网格,
     新增 LayersAllocated())才 RebuildWideBuffer();跨度/内容过期一律入队,由
     RenderPendingLaneRebuilds 逐帧补齐(原本遇到跨度不符会跳过该网格,现改为直接
     按新跨度重建);
  3) 只在视界稳定后(viewStopped)才花预算做精细重绘:视界仍在变化的每一帧只贴拉伸
     占位图,避免缩放序列每帧夹带一个网格的整块重建(宽跨度约 20ms)。
- 实测:缩放后首帧 70~80ms → ~10ms;精细重绘分散到后续帧(宽跨度 ~20ms/帧、
  深跨度 ~13ms/帧),收敛后由"结算"整幅帧保证像素精确。
- 保留"结算"帧:实测宽基底路径与从零整幅重绘并非像素同源(图层 2448px 按 1px 列聚合
  vs 整幅 1224px 列,列相位差对稠密脉冲信号放大成 17 万+ 像素差异),必须保留这次
  全质量整幅重绘来收敛。
- 正确性 A/B:缩放收敛后的画面 vs 从零整幅渲染,改动前后同为 3774 像素差异
  (预存在的列相位/装饰残差),非本次引入。

三、超大信号拖预览滑块不再拖慢前台(部分改善)
- 量化:无预览基线 0.38ms/帧;预览 + 参数不变 3.4ms/帧;预览 + 参数每帧变化
  11.8~15ms/帧;而直接调用 PaintLaneDerivedOverlays 仅 0.375ms —— 开销不在绘制,
  而在"参数每帧变化"触发的整条信号(2576 万采样)预览曲线重算调度(预览曲线按整条
  信号计算以保证与「构建为新信号」逐位一致)。
- 缓解:新增 kHugeSignalPreviewSamples = 4000000 —— 信号超过该规模且正在拖拽时,
  不再每帧排队整条信号的重算,沿用旧曲线、松手后再算一次最新的。实测 15.0→13.7ms。
- 未完全解决:参数变化带来的 ~8ms/帧开销尚未完全归因(已排除绘制;"暂停后台线程"的
  对照无效,因为 EnsureCurveWorker() 会在暂停后被重新拉起)。用户提议的"网格内按信号
  并行绘制 + 后台缓冲合成"仍需先把渲染器改成无共享可变状态,留待后续评估。

验证:pancheck(平移/纵向 7 场景)全部 dy=0;zoomcheck mode 0/13/14;
make clean && make all 零警告。文档同步更新 §9.17。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-13 01:25:39 +08:00
1 parent de27d9b94f
commit 065b948297
3 files changed
+192 -58

No files matched your search

+11 -14
View File
@@ -917,25 +917,14 @@ namespace jdi
/**
* @brief 将两条光标的时间夹取到当前可见时间范围内。
*
* ⚠️ 拖拽光标时使用;**平移**视界后也要用(见 `ClampCursorsAfterPan`)。
* 而**离散**的视图变化(选区放大 / 适应视图 / 按网格时间轴展开)必须**不**移动光标:
* 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。
* ⚠️ **仅在拖拽光标时使用**。视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**一律不**调用:
* 光标是一个固定的**时间位置**,视界移动时它必须留在原处(可以滑出可视区),
* 此时仅把它的取值/时间读数贴在网格边缘显示。
*
* @param [IN] which 要夹取的光标序号(1 或 2)。
*/
void ClampCursorToView(int which);
/**
* @brief **平移视界**之后把时间光标夹回可见区间(单/双光标都处理)。
*
* ⚠️ 与「离散视图变化不移动光标」并不矛盾:选区放大 / 适应视图 / 按网格时间轴展开
* 仍然不动光标(用户可以先量好再自由探索,光标读数贴在边缘可读、可拖回来);
* 但**平移**是连续手势 —— 尤其在深放大区间,光标一旦被落在屏外就会立刻"跑飞"
* 很远(一屏的平移就是几十甚至上百倍可视跨度),用户会以为光标丢失。
* 因此在平移(中键/左键拖拽、Shift+滚轮、方向键)之后夹一次。
*/
void ClampCursorsAfterPan();
/**
* @brief 计算双光标之间的时间差(绝对值)。
* @return 时间差;非双光标模式返回 0。
@@ -2383,6 +2372,14 @@ namespace jdi
* @return 可以直接滚动 + 贴图层像素返回 true。
*/
bool LayersStructurallyReady() const;
/**
* @brief 图层数组是否已经**与当前网格数量对齐**(首次绘制 / 增删网格后为 false)。
*
* ⚠️ 只有它返回 false 时才允许**同步**整批重建(否则没有可拉伸的旧像素可占位)。
* 跨度变化(缩放)**不**走同步重建:改为"占位拉伸 + 按预算逐个补齐"。
* @return 数量对齐返回 true。
*/
bool LayersAllocated() const;
/**
* @brief 某个网格的图层是否**完全就绪**(结构可用且内容指纹与当前状态一致)。
* @param [IN] laneIndex 网格下标。
+116 -44
View File
@@ -134,6 +134,16 @@ namespace jdi
* 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。
*/
const std::size_t kDerivedAsyncWindowThreshold = 65536;
/**
* @brief 「超大信号」门槛:信号采样数超过它时,**拖拽参数期间不再反复排队**"整条信号"的
* 预览曲线重算(见 `CurveSpanFor`)。
*
* 预览曲线按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致)。信号极大时每条
* 曲线要扫描上亿个采样,后台线程被持续占满会**反过来拖慢前台绘制**(实测前台 14ms/帧,
* 而绘制本身仅 0.4ms)。此时拖动期间沿用旧曲线、松手后再算一次最新的即可 ——
* 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。
*/
const std::size_t kHugeSignalPreviewSamples = 4000000ull;
const wchar_t* const kWeakPeakLevelLabel = L"调整幅度 %"; /**< 逐条目面板里「幅度」滑块行的标签。 */
const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */
const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
@@ -1089,8 +1099,6 @@ namespace jdi
Tab().ViewMin() += delta;
Tab().ViewMax() += delta;
ClampTimeView();
// 方向键平移同样是平移:把光标留在可见区间内(与拖拽平移一致)。
ClampCursorsAfterPan();
}
void PlotView::ScrollLanesByFraction(double fraction)
@@ -1284,7 +1292,8 @@ namespace jdi
return;
}
// 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。
// 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。
// ⚠️ 视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**不**调用本函数:光标是一个固定的
// **时间位置**,视界移动时它必须留在原处(可以滑出可视区,此时读数贴在网格边缘显示)。
const double inset = (high - low) * 0.002;
if (which == 2)
{
@@ -1296,19 +1305,6 @@ namespace jdi
}
}
void PlotView::ClampCursorsAfterPan()
{
if (m_tab == nullptr || !Tab().CursorEnabled())
{
return;
}
ClampCursorToView(1);
if (Tab().CursorMode() == PlotCursorMode::DOUBLE)
{
ClampCursorToView(2);
}
}
std::uint32_t PlotView::CursorColor() const
{
// 两条光标统一使用同一样式与颜色,不按序号区分配色。
@@ -2233,9 +2229,37 @@ namespace jdi
return &hit->values;
}
// 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
// 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
// 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。
if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold)
//
// ⚠️ **超大信号 + 正在拖动参数:不要每帧都排一次"整条信号"的重算**。
// 预览曲线一律按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致);
// 当信号极大(`VERY_BIG_DATA` 有 2576 万采样)时,每条曲线都要扫描上亿个采样,
// 后台线程会被**持续占满**(实测把可用内存带宽吃光),反过来把前台绘制拖慢
// (用户报告的"深放大下拖预览滑块非常非常卡"——实测前台每帧 14ms,而绘制本身只要 0.4ms)。
// 此时沿用旧曲线,等松手(不再处于拖拽)后再算一次最新的即可:反正整条信号的重算
// 本来也追不上滑块速度,拖动期间"冻结"与"每帧重排"在观感上没有区别,但后者会拖慢前台。
// 小信号(< `kHugeSignalPreviewSamples`)不受影响,仍保留"边拖边算"的实时预览。
if (count > kHugeSignalPreviewSamples && m_dragMode != PlotDragMode::NONE)
{
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
{
return &stale->values;
}
return nullptr;
}
// 计算窗口(含保护带):既用于同步计算,也用于判断是否值得交后台。
std::size_t computedBegin = 0;
std::size_t computedEnd = 0;
DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count,
computedBegin, computedEnd);
if (computedEnd <= computedBegin)
{
return nullptr;
}
const std::size_t computeSamples = computedEnd - computedBegin;
if (allowAsyncCompute && computeSamples > kDerivedAsyncWindowThreshold)
{
ScheduleCurveJob(key, alignedBegin, alignedEnd, count);
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
@@ -2245,15 +2269,7 @@ namespace jdi
return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制)
}
// 3) 窗口较小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。
std::size_t computedBegin = 0;
std::size_t computedEnd = 0;
DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count,
computedBegin, computedEnd);
if (computedEnd <= computedBegin)
{
return nullptr;
}
// 3) 代价很小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。
std::vector<float> values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue,
safePoints, safeIterations, safeLevel);
const CurveEntry* published =
@@ -4116,9 +4132,6 @@ namespace jdi
Tab().ViewMin() += delta;
Tab().ViewMax() += delta;
ClampTimeView();
// 平移是连续手势:必须把光标留在可见区间内,否则深放大下一屏平移就能把光标
// 甩到视界外很远(用户会认为光标“跑飞”、找不到)。
ClampCursorsAfterPan();
}
const int laneIndex = m_dragLane;
@@ -5128,8 +5141,6 @@ namespace jdi
Tab().ViewMin() += sweep;
Tab().ViewMax() += sweep;
ClampTimeView();
// Shift+滚轮也是平移:同样不能把光标落在视界之外(与拖拽平移一致)。
ClampCursorsAfterPan();
return true;
}
@@ -5799,9 +5810,14 @@ namespace jdi
}
else
{
// 结构不可用(视界跨度 / 布局变化)→ 所有网格的时间映射都变了,只能整批重建一次
//(这只是跨度变化时的一次性代价,平移过程不会触发)。
if (!LayersStructurallyReady())
// ⚠️ **绝不在这里同步整批重建全部图层**:跨度变化(选区放大 / 键盘缩放 /
// 工具栏缩放)时若一次性重建 5 个网格,实测 70~80ms —— 这正是"缩放首帧卡顿"
// 的根因。改为:
// * 只有"图层数量与网格数量不符"(首次绘制 / 增删网格)才必须同步整批建立;
// * 跨度/内容过期的网格一律登记到待重画队列,由 `RollWideBuffer` 登记、
// `RenderPendingLaneRebuilds` 按预算逐帧补齐;其间 `BlitWideBuffer`
// 会用**旧跨度像素拉伸**当占位图(低分辨率但立即可用,O(1))。
if (!LayersAllocated())
{
RebuildWideBuffer();
}
@@ -5812,7 +5828,13 @@ namespace jdi
//(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。
// ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示,
// 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。
const bool morePending = RenderPendingLaneRebuilds(1);
//
// ⚠️ **只在视界稳定后才花预算做精细重绘**:视界仍在变化(缩放 / 平移的每一帧)
// 时,本帧只贴"旧跨度像素拉伸"的占位图(O(1)),把重建推迟到视界停下来之后
// —— 这正是用户要求的"先画了再说,别卡住前台"。否则缩放序列的每一帧都会
// 夹带一个网格的整块重绘(宽跨度下约 20ms)→ 缩放过程持续掉帧。
const bool morePending =
viewStopped ? RenderPendingLaneRebuilds(1) : !m_layerRebuildPending.empty();
// 绘图区底色(覆盖时间轴与侧边留白区域)。
UiFillRect(g, m_area, m_theme->plotBg);
// 贴基底(绘图区:网格 + 波形)。图层没覆盖到的部分先留空,后续帧补齐。
@@ -7226,6 +7248,12 @@ namespace jdi
return true;
}
bool PlotView::LayersAllocated() const
{
std::lock_guard<std::mutex> lock(m_layerMutex);
return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size();
}
bool PlotView::LaneLayerReady(int laneIndex) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
@@ -7294,7 +7322,6 @@ namespace jdi
{
return !m_layerRebuildPending.empty();
}
const double span = Tab().ViewMax() - Tab().ViewMin();
const double defaultWideMin = LayerDefaultWideMin();
int done = 0;
while (!m_layerRebuildPending.empty() && done < budget)
@@ -7314,11 +7341,10 @@ namespace jdi
break;
}
LaneLayer& layer = m_layerFront[static_cast<std::size_t>(laneIndex)];
// 跨度已经又变了 → 该网格由下一次整批重建处理,这里不浪费这一帧的预算。
if (std::fabs(layer.span - span) > 1.0e-12)
{
continue;
}
// ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。
// 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"),
// 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格,
// 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。
// 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。
if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin,
m_lanes[static_cast<std::size_t>(laneIndex)].yMax))
@@ -7720,11 +7746,22 @@ namespace jdi
{
continue;
}
if (std::fabs(layer.span - span) > 1.0e-12)
const gd::RectF& lane = m_lanes[index].rect;
// 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量"。
// 高度变了(布局变化)时像素无法映射,只能等整块重画。
const int expectBitmapHeight =
static_cast<int>(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height);
if (layer.height != expectBitmapHeight)
{
continue;
}
const gd::RectF& lane = m_lanes[index].rect;
// ⚠️ **跨度过期 = 该图层是"旧跨度"的像素**(典型:选区放大 / 键盘缩放的瞬间)。
// 旧实现直接 `continue` → 整个网格在重画完成前**空白**,且触发"同步整批重建"
// (实测 70~80ms)把前台卡住。
// 现在改为把它当作**占位图**用一次 StretchBlt 拉出来:旧图层覆盖的时间段
// [layer.wideMin, layer.wideMin + layer.span] 重新映射到新的视界上 ——
// 画面略糊(低分辨率),但**立即可用**;精确像素由后续按预算重画逐个替换。
const bool spanStale = (std::fabs(layer.span - span) > 1.0e-12);
const float top = std::max(lane.Y, m_plotRect.Y);
const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height);
if (bottom <= top)
@@ -7769,6 +7806,41 @@ namespace jdi
continue;
}
if (spanStale)
{
// 占位拉伸:旧图层的时间跨度映射到当前视界(横向)。纵向映射不变。
if (!(layer.span > 0.0))
{
continue;
}
const double dx0 = static_cast<double>(destX) + (layer.wideMin - Tab().ViewMin()) * scale;
const double dx1 = dx0 + layer.span * scale;
if (!(dx1 > dx0))
{
continue;
}
const double cx0 = std::max(dx0, static_cast<double>(destX));
const double cx1 =
std::min(dx1, static_cast<double>(destX) + static_cast<double>(width));
if (cx1 - cx0 < 1.0)
{
continue;
}
const double sx0 = (cx0 - dx0) / (dx1 - dx0) * static_cast<double>(layer.width);
const double sx1 = (cx1 - dx0) / (dx1 - dx0) * static_cast<double>(layer.width);
const int dstX = static_cast<int>(std::lround(cx0));
const int dstW = std::max(1, static_cast<int>(std::lround(cx1 - cx0)));
const int srcX2 = static_cast<int>(std::lround(sx0));
const int srcW = std::max(1, static_cast<int>(std::lround(sx1 - sx0)));
// HALFTONE 让拉伸后的占位图明显更接近成图(下采样尤其明显);置一次即可。
SetStretchBltMode(target, HALFTONE);
SetBrushOrgEx(target, 0, 0, nullptr);
StretchBlt(target, dstX, destY + destTopOffset, dstW, height, layer.dc, srcX2, srcY,
srcW, height, SRCCOPY);
any = true;
continue;
}
// ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。
// 这里只贴**有像素的那一段**(其余留给底色填充,后续帧由预算内重画补齐),
// 而不是整块跳过 —— 跳过会让整个网格瞬间变空("断线 / 空白"的直接原因)。
+65
View File
@@ -541,6 +541,70 @@ Documents/ 项目文档(含本文件)
「仅水平平移」「仅纵向平移」行为与像素结果不变(已由对照场景 B/C 覆盖)。
- `make clean && make all` 零警告。
### 9.17 交互流畅性三项修复:光标不再跟随视界 / 缩放首帧不再卡顿 / 超大信号拖预览不拖慢前台(本轮)
#### 一、光标不再跟随视界(用户要求)
用户报告:"启用光标时,移动视界导致光标超出视界范围,光标会被强制移动到视界边界;
我需要光标留在原位置而不是跟随视界移动。"
- 移除 `ClampCursorsAfterPan()`(含声明)及其在**平移路径**的三处调用:中键/左键拖拽平移、
`PanTimeByFraction`(方向键)、`OnMouseWheel(shift=true)`。
- `ClampCursorToView()` 保留,**只在真正拖拽光标时**使用(其内注释与头文件说明同步更新)。
- 现行为:平移/缩放视界时光标的**时间位置完全不变**,允许滑出可视区(读数贴在网格边缘显示)。
- 验证(`.preflight/perf/zoomcheck` mode 14):平移 20 帧(视界移到 `[257.6, 386.4]s`,光标时间
`38.643036` 已远在视口外)后 `CursorTime` **完全不变**(`unchanged=1`, `offViewport=1`);
Shift+滚轮平移同样不变。
#### 二、缩放首帧不再卡顿(用户猜测"必要像素量级飙升导致首帧卡住"——**证实**)
- 量化(`.preflight/perf/zoomcheck` mode 0,`VERY_BIG_DATA` 5 网格逐步放大,逐帧计时):
每次缩放变化后 **f1=70~80ms(宽跨度)**、f2=26~40ms,f3 起 0.5ms。
- 根因:跨度变化时 `Paint` 里的 `if (!LayersStructurallyReady()) RebuildWideBuffer();` 会
**同步重建全部 5 个网格图层**(70~80ms);随后一帧又做一次整幅"结算"(40ms)→ 单次缩放
合计约 110ms 的卡顿。
- 修法(用户方案:先用旧图拉伸占位、不卡前台):
1. **旧跨度像素拉伸占位**:`BlitWideBuffer` 遇到 `layer.span != 当前 span` **不再跳过**
(跳过会让网格在重画完成前空白),而是把旧图层覆盖的时间段 `[layer.wideMin, +layer.span]`
重新映射到当前视界,用一次 `StretchBlt`(`HALFTONE`)拉出**低分辨率但立即可用**的画面。
2. `Paint` **不再同步整批重建**:只有"图层数量与网格数量不符"(首次绘制 / 增删网格,
新增 `LayersAllocated()`)才 `RebuildWideBuffer()`;跨度 / 内容过期一律入队,由
`RenderPendingLaneRebuilds` 逐帧补齐(原逻辑遇到跨度不符会跳过该网格,现改为直接按**新跨度**重建)。
3. **只在视界稳定后**(`viewStopped`)才花预算做精细重绘:视界仍在变化的每一帧只贴拉伸占位图,
否则缩放序列的每一帧都会夹带一个网格的整块重建(宽跨度约 20ms)→ 持续掉帧。
- 实测:缩放后**首帧 70~80ms → ~10ms**;精细重绘分散到后续帧(宽跨度 ~20ms/帧,深跨度 ~13ms/帧)。
收敛后仍由"结算"整幅帧保证像素精确。
- ⚠️ **保留"结算"帧**(不可省):实测宽基底路径与从零整幅重绘**并非像素同源**
(图层 2448px 按 1px 列聚合 vs 整幅 1224px 列,列相位差对稠密脉冲信号放大成 17 万+ 像素差异),
因此必须有这一次全质量整幅重绘来收敛。
- 正确性 A/B(mode 13):缩放并收敛后的画面 vs 从零整幅渲染,改动前后**同为 3774 像素差异**
(预存在的列相位/装饰残差),**非本次引入**。
#### 三、超大信号拖预览滑块不再拖慢前台(部分改善,根因未完全定位)
- 量化(mode 7/10/12,深放大,单网格预览,模拟面板滑块拖拽 `m_dragMode=PEAK_LEVEL`):
- 无预览基线(mode 5):0.38ms/帧。
- 预览 + **参数不变**(mode 12):3.4ms/帧。
- 预览 + **参数每帧变化**(mode 10):11.8ms/帧;弱峰 10 迭代(mode 7):15ms/帧。
- **直接调用 `PaintLaneDerivedOverlays`(mode 9):0.375ms** —— 说明开销**不在曲线绘制**。
- 结论:开销来自"参数每帧变化"触发的**整条信号**(2576 万采样)预览曲线重算调度
(预览曲线按整条信号计算,以保证与「构建为新信号」逐位一致)。
- 缓解:新增 `kHugeSignalPreviewSamples = 4000000` —— 信号超过该规模且**正在拖拽**(`m_dragMode != NONE`)时,
不再每帧排队"整条信号"的重算,改为沿用旧曲线、松手后再算一次最新的(整条信号重算本来也追不上
滑块速度,"冻结"与"每帧重排"观感相同,后者却持续占用后台线程与内存带宽)。
实测 15.0 → 13.7ms(改善有限)。
- ⚠️ **未完全解决**:参数变化带来的 ~8ms/帧开销**尚未完全归因**(已排除绘制;"暂停后台线程"的
对照无效,因为 `EnsureCurveWorker()` 会在暂停后被重新拉起)。用户提议的"网格内按信号并行绘制 +
后台缓冲合成"仍是候选方案,但需先把渲染器改造成**无共享可变状态**(与 §11.2 第 5 条硬约束冲突),
留待后续评估。
#### 四、验证
- `.preflight/perf/pancheck`(平移 / 纵向,7 个场景):全部 `dy=0`(原"中间网格 +1px"也归零)。
- `.preflight/perf/zoomcheck`:mode 0(逐帧首帧耗时)、mode 13(缩放收敛,A/B 无回归)、
mode 14(光标不跟随视界)。
- `make clean && make all` 零警告。
---
## 10. 变更日志(本文件维护记录)
@@ -572,6 +636,7 @@ Documents/ 项目文档(含本文件)
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **「弱峰调整」升级为三组识别规则 + 滑动条 ±400% 梯度驱动**:第一类(两侧均值都要满足,原规则)在滑动条 0–200% 内走完自身 0–200%;第二类("或":任一侧均值满足即可)从 100% 起参与,own 幅度 = clamp(|a|-100%,0,200%);第三类(只与左右同类极值比较,最宽松)从 200% 起参与,own 幅度 = clamp(|a|-200%,0,200%);负值完全对称。每组自身的 ±200% 映射统一为:+100% → 左右同类极值中较温和的一侧、**+200% → 左右同类极值的均值**(不再取较大的那个);-100% → 相邻异类极值中较近的一个、**-200% → 相邻异类极值的均值**(不再取最小的那个)。同时满足多组时取**幅度绝对值最大**的组。验证:`uitest` **463/463**(第一类点 0/50/100/150/200% → 520/570/620/**625/630**;只满足第二类的峰 130 → 100% 不动、150%/200%/250%/300%/400% = 140/150/162.5/175/175;只满足第三类的峰 195 → 200% 不动、250%/300%/400% = 197.5/200/200;±400% 夹取;滑块中间 = 0%、两端 = ±400%)、`panperf`/`layercheck`/`cursorcheck`/`dragcheck` 全过、零警告。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住中键把视界纵向移动好几个网格高(或横向拖很远)后,出现大量绘制错误与持续抽搐"**:根因是 `RollWideBuffer` 对"位移超出余量"只做**排队等后续帧重画**(`QueueLaneRebuild` + `continue`)—— 该帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;且每帧都重新入队而 `RenderPendingLaneRebuilds(1)` 一帧只补 1 个网格 → **补不完 = 持续抽搐**;纵向余量只有 ±半个网格高,正常速度的纵向拖拽每帧就超过它,故几乎必现。修法:**只有"内容真的变了"才排队**,距离类问题一律本帧就地解决 —— 位移小于画布则滚动(O(1) BitBlt + 只补暴露细条),超出画布则**立刻整块重画并重新居中**(0.4ms/网格)。验证:新增探针 `.preflight/dragcheck` 按用户要求**全程按住中键不放**地做检查(图层条带逐像素 / 画布覆盖率 / 画面空白行数与墨迹占比),并把纵向滚动临时关掉做 A/B:立刻报 `layer band diff 76221 / drawing errors 11`,恢复后 **0 错误**;`uitest` **463/463**、`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 |
| 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` 零警告。 |
---