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

一、光标不再跟随视界(用户要求)
- 删除 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

+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;
}
// ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。
// 这里只贴**有像素的那一段**(其余留给底色填充,后续帧由预算内重画补齐),
// 而不是整块跳过 —— 跳过会让整个网格瞬间变空("断线 / 空白"的直接原因)。