Compare commits
2
Commits
de27d9b94f
...
20b90848fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b90848fe | ||
|
|
065b948297 |
No files matched your search
@@ -917,25 +917,14 @@ namespace jdi
|
||||
/**
|
||||
* @brief 将两条光标的时间夹取到当前可见时间范围内。
|
||||
*
|
||||
* ⚠️ 拖拽光标时使用;**平移**视界后也要用(见 `ClampCursorsAfterPan`)。
|
||||
* 而**离散**的视图变化(选区放大 / 适应视图 / 按网格时间轴展开)必须**不**移动光标:
|
||||
* 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。
|
||||
* ⚠️ **仅在拖拽光标时使用**。视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**一律不**调用:
|
||||
* 光标是一个固定的**时间位置**,视界移动时它必须留在原处(可以滑出可视区),
|
||||
* 此时仅把它的取值/时间读数贴在网格边缘显示。
|
||||
*
|
||||
* @param [IN] which 要夹取的光标序号(1 或 2)。
|
||||
*/
|
||||
void ClampCursorToView(int which);
|
||||
|
||||
/**
|
||||
* @brief **平移视界**之后把时间光标夹回可见区间(单/双光标都处理)。
|
||||
*
|
||||
* ⚠️ 与「离散视图变化不移动光标」并不矛盾:选区放大 / 适应视图 / 按网格时间轴展开
|
||||
* 仍然不动光标(用户可以先量好再自由探索,光标读数贴在边缘可读、可拖回来);
|
||||
* 但**平移**是连续手势 —— 尤其在深放大区间,光标一旦被落在屏外就会立刻"跑飞"
|
||||
* 很远(一屏的平移就是几十甚至上百倍可视跨度),用户会以为光标丢失。
|
||||
* 因此在平移(中键/左键拖拽、Shift+滚轮、方向键)之后夹一次。
|
||||
*/
|
||||
void ClampCursorsAfterPan();
|
||||
|
||||
/**
|
||||
* @brief 计算双光标之间的时间差(绝对值)。
|
||||
* @return 时间差;非双光标模式返回 0。
|
||||
@@ -1891,6 +1880,23 @@ namespace jdi
|
||||
|
||||
mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */
|
||||
|
||||
/**
|
||||
* @brief 图层**横向补条**渲染期间的折线裁剪范围(省掉整段折线的重建与描边)。
|
||||
*
|
||||
* 横向补条只覆盖图层位图的**一条窄竖带**(`m_area` 的 x 区间),但折线原本会按
|
||||
* `m_plotRect`(整条宽窗口 = 2× 视界)构建整段顶点并交给 GDI+ 描边 —— 即使只画
|
||||
* 一条窄带,`DrawLines`/`FillPath` 仍要逐段提交,实测在"逐点描边"区间单帧高达
|
||||
* **13ms**(这正是"深放大下中键拖拽非常卡顿"的根因)。
|
||||
* 因此补条渲染时把折线的采样区间按条带的 x 范围裁剪(两侧各留少量像素余量以避免
|
||||
* 接缝),只构建并绘制落在条带内的那一段顶点。条带之外的像素由已有图层像素提供,
|
||||
* 因此结果仍与整块重画**逐像素一致**。
|
||||
*/
|
||||
bool m_stripRenderActive = false;
|
||||
float m_stripX0 = 0.0f; /**< 条带左边界(与 `m_plotRect` 同坐标系)。 */
|
||||
float m_stripX1 = 0.0f; /**< 条带右边界。 */
|
||||
/** 条带渲染专用的折线结果(**不进缓存** —— 它只覆盖条带区间,不能当作整段折线复用)。 */
|
||||
mutable PolylineCacheEntry m_stripPolylineEntry;
|
||||
|
||||
/**
|
||||
* @brief 静态场景缓存的几何/内容键。
|
||||
*
|
||||
@@ -2383,6 +2389,14 @@ namespace jdi
|
||||
* @return 可以直接滚动 + 贴图层像素返回 true。
|
||||
*/
|
||||
bool LayersStructurallyReady() const;
|
||||
/**
|
||||
* @brief 图层数组是否已经**与当前网格数量对齐**(首次绘制 / 增删网格后为 false)。
|
||||
*
|
||||
* ⚠️ 只有它返回 false 时才允许**同步**整批重建(否则没有可拉伸的旧像素可占位)。
|
||||
* 跨度变化(缩放)**不**走同步重建:改为"占位拉伸 + 按预算逐个补齐"。
|
||||
* @return 数量对齐返回 true。
|
||||
*/
|
||||
bool LayersAllocated() const;
|
||||
/**
|
||||
* @brief 某个网格的图层是否**完全就绪**(结构可用且内容指纹与当前状态一致)。
|
||||
* @param [IN] laneIndex 网格下标。
|
||||
@@ -2403,10 +2417,38 @@ namespace jdi
|
||||
*/
|
||||
bool RenderPendingLaneRebuilds(int budget);
|
||||
/** @brief 使全部图层失效。 */
|
||||
void InvalidateWideBuffer();
|
||||
/** @brief 释放全部图层位图。 */
|
||||
void InvalidateWideBuffer(); /** @brief 释放全部图层位图。 */
|
||||
void ReleaseWideBuffer();
|
||||
|
||||
/**
|
||||
* @brief **并行**重建多个网格的信号图层(多核;用于"视界已停"时一次性补齐所有过期网格)。
|
||||
*
|
||||
* ⚠️ 并行化方式:为每条工作线程准备一个**隔离的 `PlotView`**(它把 `m_tab` 置空、
|
||||
* 用自带的 `m_localTab` 副本),因此 `RenderLaneLayerRegion` 改写 `m_area` / `m_plotRect` /
|
||||
* 视界 / 网格矩形时**只作用于该线程自己的副本**,不会与 UI 线程或其它工作线程互相覆盖
|
||||
*(这正是 2026-10-12 移除"后台图层线程"的根因;用隔离上下文即可安全并行)。
|
||||
* 合成只需各线程把自己那块位图交给主线程(`std::swap` 交换 `m_layerFront`)。
|
||||
* @param [IN] laneIndices 要重建的网格下标(各自独立)。
|
||||
* @param [IN] wideMin 图层位图 `x=0` 对应的时间(各网格一致,即"以当前视界居中")。
|
||||
*/
|
||||
void RebuildLaneLayersParallel(const std::vector<int>& laneIndices, double wideMin);
|
||||
/** @brief 创建(或按需扩容)并行图层渲染的工作线程上下文。 */
|
||||
void EnsureLayerWorkers();
|
||||
/** @brief 释放并行图层渲染的工作线程上下文与暂存图层。 */
|
||||
void ReleaseLayerWorkers();
|
||||
|
||||
/** 并行图层渲染的工作上下文(每线程一个,惰性创建;每个都有自己的 `m_workerTab`)。 */
|
||||
std::vector<std::unique_ptr<PlotView>> m_layerWorkers;
|
||||
int m_layerWorkerCount = 0;
|
||||
/**
|
||||
* @brief 工作线程实例**自有的**标签页配置(`m_tab` 指向它)。
|
||||
*
|
||||
* 工作线程的 `RenderLaneLayerRegion` 会改写视界 / 网格矩形;让它改在自己这份副本上,
|
||||
* 就不会碰到 UI 线程共享的 `TabConfig`(安全并行的前提)。
|
||||
* ⚠️ `m_tab` **必须非空** —— `BuildLaneVisuals()` 在 `m_tab == nullptr` 时会清空 `m_lanes`。
|
||||
*/
|
||||
TabConfig m_workerTab;
|
||||
|
||||
std::vector<LaneLayer> m_layerFront; /**< 各网格的「信号图层」(与 `m_lanes` 同序)。 */
|
||||
/** 待重画的网格下标(去重;`RollWideBuffer` 登记,`RenderPendingLaneRebuilds` 消费)。 */
|
||||
std::vector<int> m_layerRebuildPending;
|
||||
|
||||
@@ -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"弱峰调整"; /**< 弱峰调整面板标题。 */
|
||||
@@ -411,10 +421,11 @@ namespace jdi
|
||||
* @param [IN] columns 逐列极值(同一段内按列顺序排列)。
|
||||
* @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。
|
||||
* @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。
|
||||
* @param [OUT] spiky 该段是否为「梳状」(相邻列包络几乎不相交)。
|
||||
* @param [OUT] spiky 该段是否为「梳状 / 密集满幅」(建议关闭抗锯齿)。
|
||||
* @param [IN] laneHeight 网格的可见像素高度(用于「覆盖率」判据;≤0 时跳过)。
|
||||
*/
|
||||
void EmitEnvelopeBand(const std::vector<BandColumn>& columns, std::vector<gd::PointF>& band,
|
||||
std::vector<std::size_t>& bandBreaks, bool& spiky)
|
||||
std::vector<std::size_t>& bandBreaks, bool& spiky, float laneHeight)
|
||||
{
|
||||
if (columns.size() < 2)
|
||||
{
|
||||
@@ -440,6 +451,43 @@ namespace jdi
|
||||
spiky = true;
|
||||
}
|
||||
|
||||
// 「密集满幅」判定:包络带在**每一列内部**都横跨了网格的大部分高度
|
||||
//(编码器方波在 1~2 采样/像素时,多数像素列里都含一次整幅跳变)。
|
||||
// GDI+ **抗锯齿**的代价与这些近竖直、满幅的边成正比:实测同一 4832 顶点的几何
|
||||
// **抗锯齿 694ms、关抗锯齿 29ms(23 倍)**,而这么密的色块上抗锯齿的平滑作用肉眼不可辨。
|
||||
// ⚠️ 只影响「本段是否关抗锯齿」,几何(逐列 min/max)完全不变。
|
||||
if (laneHeight > 0.0f)
|
||||
{
|
||||
std::size_t tallColumns = 0;
|
||||
double covered = 0.0;
|
||||
for (const BandColumn& column : columns)
|
||||
{
|
||||
const float span = column.bottom - column.top;
|
||||
covered += static_cast<double>(span);
|
||||
if (span > 0.5f * laneHeight)
|
||||
{
|
||||
++tallColumns;
|
||||
}
|
||||
}
|
||||
// 「锯齿」判定(最能代表 GDI+ 抗锯齿代价):相邻列的上下包络**整幅跳变**。
|
||||
std::size_t jumps = 0;
|
||||
const float jumpThreshold = 0.25f * laneHeight;
|
||||
for (std::size_t index = 1; index < count; ++index)
|
||||
{
|
||||
const float dTop = std::fabs(columns[index].top - columns[index - 1].top);
|
||||
const float dBottom = std::fabs(columns[index].bottom - columns[index - 1].bottom);
|
||||
if (dTop > jumpThreshold || dBottom > jumpThreshold)
|
||||
{
|
||||
++jumps;
|
||||
}
|
||||
}
|
||||
if (tallColumns * 4 > count || jumps * 4 > count ||
|
||||
covered > 0.45 * static_cast<double>(count) * static_cast<double>(laneHeight))
|
||||
{
|
||||
spiky = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t index = 0; index < count; ++index)
|
||||
{
|
||||
band.push_back(gd::PointF(columns[index].x, columns[index].top));
|
||||
@@ -731,6 +779,8 @@ namespace jdi
|
||||
{
|
||||
// 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。
|
||||
StopCurveWorker();
|
||||
// 并行图层渲染的工作上下文(各自持有图层位图,须在 GDI+ 关闭前释放)。
|
||||
ReleaseLayerWorkers();
|
||||
ReleaseSceneCache();
|
||||
ReleaseDecorCache();
|
||||
ReleaseWideBuffer();
|
||||
@@ -1089,8 +1139,6 @@ namespace jdi
|
||||
Tab().ViewMin() += delta;
|
||||
Tab().ViewMax() += delta;
|
||||
ClampTimeView();
|
||||
// 方向键平移同样是平移:把光标留在可见区间内(与拖拽平移一致)。
|
||||
ClampCursorsAfterPan();
|
||||
}
|
||||
|
||||
void PlotView::ScrollLanesByFraction(double fraction)
|
||||
@@ -1284,7 +1332,8 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
// 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。
|
||||
// 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。
|
||||
// ⚠️ 视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**不**调用本函数:光标是一个固定的
|
||||
// **时间位置**,视界移动时它必须留在原处(可以滑出可视区,此时读数贴在网格边缘显示)。
|
||||
const double inset = (high - low) * 0.002;
|
||||
if (which == 2)
|
||||
{
|
||||
@@ -1296,19 +1345,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 +2269,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 +2309,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 +4172,6 @@ namespace jdi
|
||||
Tab().ViewMin() += delta;
|
||||
Tab().ViewMax() += delta;
|
||||
ClampTimeView();
|
||||
// 平移是连续手势:必须把光标留在可见区间内,否则深放大下一屏平移就能把光标
|
||||
// 甩到视界外很远(用户会认为光标“跑飞”、找不到)。
|
||||
ClampCursorsAfterPan();
|
||||
}
|
||||
|
||||
const int laneIndex = m_dragLane;
|
||||
@@ -5128,8 +5181,6 @@ namespace jdi
|
||||
Tab().ViewMin() += sweep;
|
||||
Tab().ViewMax() += sweep;
|
||||
ClampTimeView();
|
||||
// Shift+滚轮也是平移:同样不能把光标落在视界之外(与拖拽平移一致)。
|
||||
ClampCursorsAfterPan();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5262,6 +5313,17 @@ namespace jdi
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ⚠️ **横向补条渲染:不进缓存**。补条只构建"条带区间"的折线顶点,若写进缓存,
|
||||
// 后续整块渲染命中同键时会拿到这段**不完整**的折线(只画了条带那一段)。
|
||||
// 因此补条专用一个独立条目,避免污染按"宽窗口几何"键控的缓存。
|
||||
if (m_stripRenderActive)
|
||||
{
|
||||
BuildPolyline(resolved, lane, m_stripPolylineEntry.points, m_stripPolylineEntry.breaks,
|
||||
m_stripPolylineEntry.band, m_stripPolylineEntry.bandBreaks,
|
||||
m_stripPolylineEntry.bandMode, m_stripPolylineEntry.bandSpiky);
|
||||
return &m_stripPolylineEntry;
|
||||
}
|
||||
|
||||
// 键 = 构建折线时实际读取的全部输入:
|
||||
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
|
||||
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
|
||||
@@ -5359,6 +5421,12 @@ namespace jdi
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ **横向补条裁剪**(只对本函数后半段的"逐点描边"路径生效,见 `budget` 之后):
|
||||
// 图层横向补条只覆盖位图里的一条窄竖带(`m_area` 的 x 区间),其余像素由已有图层像素提供,
|
||||
// 因此折线只需构建并绘制落在条带 x 范围内的那一段顶点。放在"包络/描边"分支判定**之后**
|
||||
// 是必须的 —— 若先裁剪,`visibleCount` 会变成条带内的采样数,可能改变"走包络还是走描边"
|
||||
// 的判定,使条带与整块渲染的像素不一致(出现接缝)。
|
||||
|
||||
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
|
||||
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
|
||||
|
||||
@@ -5372,10 +5440,62 @@ namespace jdi
|
||||
budget = std::max(budget, kMinPolylinePoints);
|
||||
budget = std::min(budget, kMaxPolylinePoints);
|
||||
|
||||
// 采样点密度超过像素分辨率时改用“像素列上下包络”抽稀:
|
||||
// 每个像素列只保留最小/最大两个顶点(按出现先后排序),
|
||||
// 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。
|
||||
//
|
||||
// ⚠️ **阈值必须是「每像素列 1 个采样」而不是 `budget`(每列 2 个)**:
|
||||
// 逐点描边(`DrawLines`)的代价与「竖向线段数 × 覆盖行数」成正比 ——
|
||||
// 方波/编码器这类信号在 1~2 采样/像素时会形成**满幅高的锯齿**,GDI+ 抗锯齿
|
||||
// 逐段扫描这些竖直段,实测 **4481 个顶点要 712ms**(关抗锯齿也无用,因为瓶颈是
|
||||
// 每段的扫描线而非边缘平滑),而同样画面的「像素列包络」只要 ~1.7ms。
|
||||
// 把阈值降到 1 采样/像素后,1~2 采样/像素区间走包络带(逐列 min/max 与折线在该密度下
|
||||
// 几乎等价,且**保留抗锯齿**),彻底消除这一区间的病态卡顿。
|
||||
const bool useEnvelopeBand = (visibleCount > static_cast<std::size_t>(m_plotRect.Width)) &&
|
||||
resolved.file->timeMonotonic && (m_plotRect.Width > 1.0f);
|
||||
|
||||
// 逐点描边路径若处于"横向补条"渲染:把采样区间裁剪到条带对应的 x 范围
|
||||
//(两侧各留少量像素余量以避免接缝与抗锯齿溢出)。逐点描边路径必然 `visibleCount <= budget`
|
||||
//(否则走包络),此时 `stride == 1`,裁剪后条带内的顶点序列与整块渲染完全一致 → 像素不变。
|
||||
if (!useEnvelopeBand && m_stripRenderActive && resolved.file->timeMonotonic &&
|
||||
m_stripX1 > m_stripX0)
|
||||
{
|
||||
const float marginPx =
|
||||
std::max(4.0f, std::max(1.0f, resolved.style->lineWidth) * m_scale + 3.0f);
|
||||
const double ta = XToTime(m_stripX0 - marginPx);
|
||||
const double tb = XToTime(m_stripX1 + marginPx);
|
||||
const double clipLo = std::min(ta, tb);
|
||||
const double clipHi = std::max(ta, tb);
|
||||
std::size_t sBegin = static_cast<std::size_t>(
|
||||
std::lower_bound(time.begin(), time.end(), clipLo) - time.begin());
|
||||
if (sBegin > 0)
|
||||
{
|
||||
--sBegin; // 多含一个采样点:让跨越条带左界的线段完整绘出。
|
||||
}
|
||||
std::size_t sEnd = static_cast<std::size_t>(
|
||||
std::lower_bound(time.begin(), time.end(), clipHi) - time.begin());
|
||||
if (sEnd < count)
|
||||
{
|
||||
++sEnd;
|
||||
}
|
||||
if (sBegin > begin)
|
||||
{
|
||||
begin = sBegin;
|
||||
}
|
||||
if (sEnd < end)
|
||||
{
|
||||
end = sEnd;
|
||||
}
|
||||
if (end <= begin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀:
|
||||
// 每个像素列只保留最小/最大两个顶点(按出现先后排序),
|
||||
// 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。
|
||||
if (visibleCount > budget && resolved.file->timeMonotonic && m_plotRect.Width > 1.0f)
|
||||
if (useEnvelopeBand)
|
||||
{
|
||||
const double viewSpan = viewMax - viewMin;
|
||||
const float plotLeft = m_plotRect.X;
|
||||
@@ -5404,10 +5524,10 @@ namespace jdi
|
||||
const float strokeHalf =
|
||||
std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f;
|
||||
std::vector<BandColumn> segmentColumns;
|
||||
const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky]()
|
||||
const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky, &lane]()
|
||||
{
|
||||
bool spiky = false;
|
||||
EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky);
|
||||
EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky, lane.rect.Height);
|
||||
bandSpiky = bandSpiky || spiky;
|
||||
segmentColumns.clear();
|
||||
};
|
||||
@@ -5799,9 +5919,14 @@ namespace jdi
|
||||
}
|
||||
else
|
||||
{
|
||||
// 结构不可用(视界跨度 / 布局变化)→ 所有网格的时间映射都变了,只能整批重建一次
|
||||
//(这只是跨度变化时的一次性代价,平移过程不会触发)。
|
||||
if (!LayersStructurallyReady())
|
||||
// ⚠️ **绝不在这里同步整批重建全部图层**:跨度变化(选区放大 / 键盘缩放 /
|
||||
// 工具栏缩放)时若一次性重建 5 个网格,实测 70~80ms —— 这正是"缩放首帧卡顿"
|
||||
// 的根因。改为:
|
||||
// * 只有"图层数量与网格数量不符"(首次绘制 / 增删网格)才必须同步整批建立;
|
||||
// * 跨度/内容过期的网格一律登记到待重画队列,由 `RollWideBuffer` 登记、
|
||||
// `RenderPendingLaneRebuilds` 按预算逐帧补齐;其间 `BlitWideBuffer`
|
||||
// 会用**旧跨度像素拉伸**当占位图(低分辨率但立即可用,O(1))。
|
||||
if (!LayersAllocated())
|
||||
{
|
||||
RebuildWideBuffer();
|
||||
}
|
||||
@@ -5812,7 +5937,15 @@ namespace jdi
|
||||
//(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。
|
||||
// ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示,
|
||||
// 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。
|
||||
const bool morePending = RenderPendingLaneRebuilds(1);
|
||||
//
|
||||
// ⚠️ **只在视界稳定后才花预算做精细重绘**(视界仍在变化的每一帧只贴"旧跨度像素拉伸"
|
||||
// 的占位图,O(1),把重建推迟到视界停下来之后 —— 用户要求"先画了再说,别卡住前台"),
|
||||
// 且**仍按预算逐个补**(不用一次把全部过期网格塞进同一帧):实测同一台机器上 4 线程
|
||||
// 真并行(3.98x),但**图层光栅化(GDI+)本身会被串行化**,并行重建 5 个网格 ≈ 逐个重建
|
||||
//(1.03x)→ 一次性补齐只会把一帧从 ~10ms 抬到 ~31ms(更容易掉帧)。
|
||||
// 因此保持"视界停后**每帧补一个网格**"(`RenderPendingLaneRebuilds` 在 `budget>1` 时才走并行)。
|
||||
const bool morePending =
|
||||
viewStopped ? RenderPendingLaneRebuilds(1) : !m_layerRebuildPending.empty();
|
||||
// 绘图区底色(覆盖时间轴与侧边留白区域)。
|
||||
UiFillRect(g, m_area, m_theme->plotBg);
|
||||
// 贴基底(绘图区:网格 + 波形)。图层没覆盖到的部分先留空,后续帧补齐。
|
||||
@@ -7226,6 +7359,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,17 +7433,31 @@ 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)
|
||||
// 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。
|
||||
const int available = static_cast<int>(m_layerRebuildPending.size());
|
||||
const int take = std::min(budget, available);
|
||||
std::vector<int> batch;
|
||||
batch.reserve(static_cast<std::size_t>(take));
|
||||
for (int index = 0; index < take; ++index)
|
||||
{
|
||||
const int laneIndex = m_layerRebuildPending.front();
|
||||
m_layerRebuildPending.erase(m_layerRebuildPending.begin());
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
||||
const int laneIndex = m_layerRebuildPending[static_cast<std::size_t>(index)];
|
||||
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
|
||||
{
|
||||
continue;
|
||||
batch.push_back(laneIndex);
|
||||
}
|
||||
}
|
||||
m_layerRebuildPending.erase(m_layerRebuildPending.begin(),
|
||||
m_layerRebuildPending.begin() + take);
|
||||
|
||||
if (batch.size() > 1)
|
||||
{
|
||||
// **多网格一次补齐**:并行渲染(各网格用隔离上下文),比"一帧只补 1 个"收敛快 N 倍。
|
||||
RebuildLaneLayersParallel(batch, defaultWideMin);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const int laneIndex : batch)
|
||||
{
|
||||
// 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。
|
||||
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
|
||||
@@ -7314,11 +7467,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))
|
||||
@@ -7326,11 +7478,178 @@ namespace jdi
|
||||
continue;
|
||||
}
|
||||
}
|
||||
++done;
|
||||
}
|
||||
return !m_layerRebuildPending.empty();
|
||||
}
|
||||
|
||||
void PlotView::EnsureLayerWorkers()
|
||||
{
|
||||
unsigned int hardware = std::thread::hardware_concurrency();
|
||||
const int want = std::max(1, std::min(static_cast<int>(hardware == 0 ? 2u : hardware), 4));
|
||||
if (m_layerWorkerCount == want && static_cast<int>(m_layerWorkers.size()) == want)
|
||||
{
|
||||
// 字体 / 主题 / 缩放 / 数据源可能已变化:刷新一次绑定(不重建线程上下文)。
|
||||
for (int index = 0; index < want; ++index)
|
||||
{
|
||||
m_layerWorkers[static_cast<std::size_t>(index)]->Setup(m_fonts, m_theme);
|
||||
m_layerWorkers[static_cast<std::size_t>(index)]->SetScale(m_scale);
|
||||
m_layerWorkers[static_cast<std::size_t>(index)]->m_dataSet = m_dataSet;
|
||||
m_layerWorkers[static_cast<std::size_t>(index)]->m_dataSetWritable = m_dataSetWritable;
|
||||
}
|
||||
return;
|
||||
}
|
||||
ReleaseLayerWorkers();
|
||||
m_layerWorkers.reserve(static_cast<std::size_t>(want));
|
||||
for (int index = 0; index < want; ++index)
|
||||
{
|
||||
auto worker = std::make_unique<PlotView>();
|
||||
worker->Setup(m_fonts, m_theme);
|
||||
worker->SetScale(m_scale);
|
||||
// ⚠️ `m_tab` 置空 → worker 的 `Tab()` 返回它**自带的** `m_localTab` 副本,
|
||||
// 于是它改写视界 / 网格矩形时不会碰到共享的标签页配置(安全并行的前提)。
|
||||
worker->m_dataSet = m_dataSet;
|
||||
worker->m_dataSetWritable = m_dataSetWritable;
|
||||
// ⚠️ `m_tab` **必须非空**(`BuildLaneVisuals()` 在空时会清空 `m_lanes`);
|
||||
// 指向 worker **自有**的 `m_workerTab`,这样它改写视界 / 网格矩形时不会碰到共享标签页。
|
||||
worker->m_tab = &worker->m_workerTab;
|
||||
worker->RefreshLanes();
|
||||
m_layerWorkers.push_back(std::move(worker));
|
||||
}
|
||||
m_layerWorkerCount = want;
|
||||
}
|
||||
|
||||
void PlotView::ReleaseLayerWorkers()
|
||||
{
|
||||
m_layerWorkers.clear();
|
||||
m_layerWorkerCount = 0;
|
||||
}
|
||||
|
||||
void PlotView::RebuildLaneLayersParallel(const std::vector<int>& laneIndices, double wideMin)
|
||||
{
|
||||
if (laneIndices.empty() || m_tab == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 单网格 / 单核:直接顺序走既有路径(避免线程开销)。
|
||||
unsigned int hardware = std::thread::hardware_concurrency();
|
||||
if (laneIndices.size() <= 1 || (hardware == 0 ? 2u : hardware) <= 1)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
|
||||
std::lock_guard<std::mutex> layerLock(m_layerMutex);
|
||||
if (m_layerFront.size() != m_lanes.size())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (const int laneIndex : laneIndices)
|
||||
{
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
RenderLaneLayerRegion(laneIndex, m_layerFront[static_cast<std::size_t>(laneIndex)], 0, 0, 0,
|
||||
0, wideMin, m_lanes[static_cast<std::size_t>(laneIndex)].yMax);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
|
||||
std::lock_guard<std::mutex> layerLock(m_layerMutex);
|
||||
if (m_layerFront.size() != m_lanes.size())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
EnsureLayerWorkers();
|
||||
if (m_layerWorkerCount <= 1)
|
||||
{
|
||||
return; // 保守回退:让调用方走顺序路径
|
||||
}
|
||||
|
||||
// 主线程**先快照**工作线程需要的一切(之后它们只读,不再触碰共享状态)。
|
||||
const TabConfig tabSnapshot = *m_tab;
|
||||
const gd::RectF plotRectSnapshot = m_plotRect;
|
||||
const gd::RectF areaSnapshot = m_area;
|
||||
const float scaleSnapshot = m_scale;
|
||||
const std::vector<LaneVisual> laneSnapshot = m_lanes;
|
||||
// 目标图层位图数组(已确认尺寸与网格数一致,线程内不会扩容)。
|
||||
std::vector<LaneLayer>* const front = &m_layerFront;
|
||||
|
||||
const int workerCount = m_layerWorkerCount;
|
||||
std::atomic<std::size_t> next{0};
|
||||
std::vector<std::thread> threads;
|
||||
threads.reserve(static_cast<std::size_t>(workerCount));
|
||||
for (int workerIndex = 0; workerIndex < workerCount; ++workerIndex)
|
||||
{
|
||||
threads.emplace_back([&, workerIndex]()
|
||||
{
|
||||
PlotView* worker = m_layerWorkers[static_cast<std::size_t>(workerIndex)].get();
|
||||
for (;;)
|
||||
{
|
||||
const std::size_t slot = next.fetch_add(1, std::memory_order_relaxed);
|
||||
if (slot >= laneIndices.size())
|
||||
{
|
||||
break;
|
||||
}
|
||||
const int laneIndex = laneIndices[slot];
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(laneSnapshot.size()) ||
|
||||
laneIndex >= static_cast<int>(tabSnapshot.Lanes().size()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 隔离上下文:自带标签页副本 + 只含该网格 + 只读的数据源 / 字体 / 主题。
|
||||
worker->m_workerTab = tabSnapshot;
|
||||
worker->m_workerTab.Lanes() = {tabSnapshot.Lanes()[static_cast<std::size_t>(laneIndex)]};
|
||||
worker->m_tab = &worker->m_workerTab;
|
||||
worker->m_fonts = m_fonts;
|
||||
worker->m_theme = m_theme;
|
||||
worker->m_scale = scaleSnapshot;
|
||||
worker->m_dataSet = m_dataSet;
|
||||
worker->m_dataSetWritable = m_dataSetWritable;
|
||||
worker->m_area = areaSnapshot;
|
||||
worker->m_plotRect = plotRectSnapshot;
|
||||
worker->m_lanes = {laneSnapshot[static_cast<std::size_t>(laneIndex)]};
|
||||
// ⚠️ 工作线程用**自己的** `m_workerTab` / `m_area` / `m_plotRect` / `m_lanes` 渲染,
|
||||
// 因此不会与 UI 线程或其它工作线程互相覆盖渲染状态(这正是 2026-10-12 移除
|
||||
// "后台图层线程"的根因)。目标图层位图按 `laneIndex` 各自不同,可并发写入。
|
||||
worker->RenderLaneLayerRegion(0, (*front)[static_cast<std::size_t>(laneIndex)], 0, 0, 0, 0,
|
||||
wideMin,
|
||||
laneSnapshot[static_cast<std::size_t>(laneIndex)].yMax);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (std::thread& thread : threads)
|
||||
{
|
||||
if (thread.joinable())
|
||||
{
|
||||
thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
// 合并(主线程):工作线程内部以 `laneIndex = 0` 渲染,因此图层内容指纹要按**真实网格下标**重算。
|
||||
std::lock_guard<std::mutex> layerLock(m_layerMutex);
|
||||
if (m_layerFront.size() != m_lanes.size())
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::uint64_t rendered = 0;
|
||||
for (const int laneIndex : laneIndices)
|
||||
{
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_layerFront.size()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
LaneLayer& target = m_layerFront[static_cast<std::size_t>(laneIndex)];
|
||||
if (!target.valid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
target.key = LaneLayerKey(laneIndex);
|
||||
++rendered;
|
||||
}
|
||||
m_layerRenderCount.fetch_add(rendered, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW,
|
||||
int stripY, int stripH, double wideMin, double anchorYMax)
|
||||
{
|
||||
@@ -7395,6 +7714,15 @@ namespace jdi
|
||||
// x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。
|
||||
m_area = gd::RectF(static_cast<float>(stripX), static_cast<float>(stripY),
|
||||
static_cast<float>(stripW), static_cast<float>(stripH));
|
||||
// 横向补条(窄竖带):把折线的构建与描边裁剪到条带的 x 范围(见 `m_stripRenderActive`)。
|
||||
// 纵向补条(整宽 × 窄 y)不裁剪 x —— 那一方向本来就需要整条折线的顶点。
|
||||
const bool stripIsHorizontalBand = (static_cast<float>(stripW) < static_cast<float>(bitmapWidth) - 0.5f);
|
||||
const bool savedStripActive = m_stripRenderActive;
|
||||
const float savedStripX0 = m_stripX0;
|
||||
const float savedStripX1 = m_stripX1;
|
||||
m_stripRenderActive = stripIsHorizontalBand;
|
||||
m_stripX0 = static_cast<float>(stripX);
|
||||
m_stripX1 = static_cast<float>(stripX + stripW);
|
||||
Tab().ViewMin() = wideMin;
|
||||
Tab().ViewMax() = wideMin + static_cast<double>(bitmapWidth) / scale;
|
||||
|
||||
@@ -7456,6 +7784,9 @@ namespace jdi
|
||||
m_renderWide = savedRenderWide;
|
||||
m_wideTickSpan = savedWideTickSpan;
|
||||
m_wideTickHeight = savedWideTickHeight;
|
||||
m_stripRenderActive = savedStripActive;
|
||||
m_stripX0 = savedStripX0;
|
||||
m_stripX1 = savedStripX1;
|
||||
|
||||
// 恢复布局 / 视图 / 网格。
|
||||
// ⚠️ **必须调用 `BuildLaneVisuals()`**(而不是只把 `m_lanes` 拷回去):它会按正常布局
|
||||
@@ -7504,19 +7835,31 @@ namespace jdi
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
|
||||
std::lock_guard<std::mutex> layerLock(m_layerMutex);
|
||||
m_layerFront.resize(m_lanes.size());
|
||||
m_layerRebuildPending.clear();
|
||||
// 只在**扩容 + 清空待重画队列**这一段持有 `m_layerMutex`;
|
||||
// 随后释放,`RebuildLaneLayersParallel` 会各自重新获取(它内部还会取 `m_renderMutex`)。
|
||||
// ⚠️ 如果这里一直持有 `m_layerMutex`,而并行重建内部再取一次 → `std::mutex` 不可重入 → 死锁。
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
|
||||
std::lock_guard<std::mutex> layerLock(m_layerMutex);
|
||||
m_layerFront.resize(m_lanes.size());
|
||||
m_layerRebuildPending.clear();
|
||||
}
|
||||
|
||||
// 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。
|
||||
const double wideMin = LayerDefaultWideMin();
|
||||
std::vector<int> allLanes;
|
||||
allLanes.reserve(m_lanes.size());
|
||||
for (std::size_t index = 0; index < m_lanes.size(); ++index)
|
||||
{
|
||||
allLanes.push_back(static_cast<int>(index));
|
||||
}
|
||||
// **并行**重建全部网格(各网格用隔离上下文,多核时约等于一块网格的耗时)。
|
||||
RebuildLaneLayersParallel(allLanes, wideMin);
|
||||
std::lock_guard<std::mutex> layerLock(m_layerMutex);
|
||||
bool ok = true;
|
||||
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
|
||||
{
|
||||
// 整块重画:每个图层都以"当前 `yMax`"为纵向锚点。
|
||||
if (!RenderLaneLayerRegion(static_cast<int>(index), m_layerFront[index], 0, 0, 0, 0, wideMin,
|
||||
m_lanes[index].yMax))
|
||||
if (!m_layerFront[index].valid)
|
||||
{
|
||||
ok = false;
|
||||
}
|
||||
@@ -7720,11 +8063,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 +8123,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;
|
||||
}
|
||||
|
||||
// ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。
|
||||
// 这里只贴**有像素的那一段**(其余留给底色填充,后续帧由预算内重画补齐),
|
||||
// 而不是整块跳过 —— 跳过会让整个网格瞬间变空("断线 / 空白"的直接原因)。
|
||||
|
||||
+145
-2
@@ -541,6 +541,144 @@ 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` 零警告。
|
||||
|
||||
### 9.18 深放大中键拖拽卡顿根因(逐点描边 AA 病态)+ 横向补条裁剪 + 并行网格图层(本轮)
|
||||
|
||||
用户报告:`VERY_BIG_DATA.CSV` 只显示**编码器A相**、选区放大到 `[10.2719, 10.2831] s`
|
||||
(≈1.12ms 一大格)后,中键拖拽视界**非常非常卡顿**。用户同时要求:**做 B 与 C**
|
||||
(B=拆分宽基底路径耗时定位瓶颈;C=解除"不允许多线程"限制并实现并行网格渲染)。
|
||||
|
||||
#### 一、B:宽基底路径耗时拆解(`.preflight/perf/dragperf` mode 1)
|
||||
|
||||
在该视界逐项计时:`RollWideBuffer`(补条重绘)**13.08ms** / `BlitWideBuffer` 0.50 /
|
||||
`PaintTimeAxis` 0.78 / `PaintLaneDividers` 0.04 / `PaintLaneDerivedOverlays` 0.00 /
|
||||
`CaptureDecorCache` 0.03 —— **瓶颈全在 `RollWideBuffer` 的"补条重绘"**。
|
||||
|
||||
#### 二、补条重绘为什么慢 + 裁剪修复
|
||||
|
||||
- 补条只覆盖图层位图的**一条窄竖带**(`m_area` 的 x 区间),但折线原本按 `m_plotRect`(整条宽窗口
|
||||
=2× 视界)构建整段顶点并交给 GDI+ 描边 —— 即使只画窄带,`DrawLines` 仍逐段提交。
|
||||
- 修法:新增 `m_stripRenderActive/m_stripX0/m_stripX1`,**横向补条时把折线采样区间裁剪到条带对应
|
||||
x 范围**(两侧各留 `max(4, lineWidth+3)` 像素余量,避免接缝与抗锯齿溢出),并**旁路折线缓存**
|
||||
(补条只构建片段,写进缓存会污染整段复用)。
|
||||
- ⚠️ **裁剪只对"逐点描边"路径生效,且必须在"包络/描边"分支判定之后**:若先裁剪,`visibleCount`
|
||||
会变成条带内采样数,可能改变路径判定 → 条带与整块渲染像素不一致(出现接缝)。
|
||||
- 实测 `RollWideBuffer` **13.08ms → 1.73ms**;中键拖拽整帧 **13~24ms → ~3ms**。
|
||||
- 正确性(`.preflight/perf/dragperf` mode 3):人为破坏一条竖带后用补条重绘,
|
||||
与整块渲染逐像素比较 **0 差异**(条带左/中/右/宽条 4 种位置都是 0)。
|
||||
|
||||
#### 三、真正的病态根因:GDI+ 抗锯齿描边"满幅锯齿"(本轮最关键)
|
||||
|
||||
修掉补条后,**一旦拖拽超出预渲染余量**(`RollWideBuffer` 走整块重画)或缩放落到该视界,
|
||||
仍会卡:实测**整宽单网格渲染 655ms**(补条仅 1.7ms)。逐层定位:
|
||||
|
||||
- `BuildPolyline` 只 0.03ms(4481 点、`breaks=1`);
|
||||
- 直接把同样的 4481 点交给 `Graphics::DrawLines`:**AA round ≈694ms / 无 AA ≈29ms(23 倍)**;
|
||||
- 把同样的几何做成"像素列包络带"再 `FillPath`:同样慢。
|
||||
|
||||
即:该视界下编码器方波形成**每像素列一次整幅跳变的锯齿**,采样 1~2 个/像素,
|
||||
GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(关抗锯齿无效是因为原实现只在
|
||||
`visibleCount > budget(=2/px)` 或"梳状"时关 AA,这一区间两个条件都不满足)。
|
||||
|
||||
修法(**几何完全不变,只改"何时走包络 / 何时关 AA"**):
|
||||
|
||||
1. **包络带阈值从"每像素 2 个采样(`budget`)"降到"每像素 1 个采样"**:`useEnvelopeBand =
|
||||
visibleCount > m_plotRect.Width`。1~2 采样/像素区间逐列 min/max 与折线几乎等价(且保留 AA)。
|
||||
2. **扩展"梳状(关 AA)"判据**(`EmitEnvelopeBand` 新增形参 `laneHeight`):除了原有的"相邻列包络不相交",
|
||||
再加入「多数列在列内就跨过 >0.5×网格高」或「相邻列上下包络整幅跳变(> 0.25×网格高)」
|
||||
→ 判为梳状,**本段关闭抗锯齿**(几何逐列 min/max 一字未改)。
|
||||
3. 实测:整宽单网格渲染 **655ms → 47.7ms**,横向补条 **6.9 → 2.4ms**;
|
||||
中键拖拽 200 帧:**avg 4.5ms / max 5.8ms / 0 帧 >8ms**(修复前 13~24ms 且含 700ms 尖峰)。
|
||||
4. 目视核对(`.preflight/perf/dragperf` mode 9 导出 PNG):编码器波形为正确的密集方波包络带。
|
||||
|
||||
#### 四、C:并行网格图层渲染(**用户明确解除"不允许多线程"约束**)
|
||||
|
||||
- 文档中"图层渲染一律在 UI 线程内同步完成 / 绝不要搬到后台线程"的约束**已按用户要求解除**
|
||||
(见 §11.2 第 5、14 条的新表述),但**根因(共享可变渲染状态)依然成立** —— 因此并行的
|
||||
唯一安全姿势是**每条线程持有隔离的渲染上下文**。
|
||||
- 实现(`PlotView::RebuildLaneLayersParallel`):为每条工作线程准备一个**隔离的 `PlotView`**
|
||||
(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),
|
||||
各自把不同 `laneIndex` 的图层渲染到**不同的 `m_layerFront` 元素**;主线程 join 后按**真实网格下标**
|
||||
重算 `LaneLayerKey`(工作线程内部按 `laneIndex=0` 渲染)。
|
||||
- ⚠️ **踩坑**:`BuildLaneVisuals()` 在 `m_tab == nullptr` 时**会清空 `m_lanes`** → 不能靠"把 `m_tab` 置空
|
||||
来隔离",必须给它一个**非空的、自有的** `TabConfig`(`m_workerTab`)。
|
||||
- 正确性(`.preflight/perf/dragperf` mode 5):并行 vs 顺序逐网格比较 **0 差异**(5 网格全 0)。
|
||||
- **收益(诚实结论)**:`RebuildWideBuffer` 并行 31.1ms vs 顺序 32.0ms → **仅 1.03x**。
|
||||
同一进程自检证明 **4 线程 CPU 真并行(3.98x,hw=20)**,因此**瓶颈是 GDI+ 光栅化本身的串行化**,
|
||||
不是调度。→ **并行对"光栅化"没有收益**;因此**保持"视界停后每帧补 1 个网格"**的策略
|
||||
(把一帧从 ~10ms 抬到 ~31ms 反而更容易掉帧),并行只用于"本来就要一次画完"的 `RebuildWideBuffer()`。
|
||||
|
||||
#### 五、验证
|
||||
|
||||
- `.preflight/perf/dragperf`:mode 0(拖拽帧时)、mode 1(B 拆解)、mode 3(补条逐像素)、
|
||||
mode 5(并行逐像素)、mode 6(重建耗时)、mode 8(线程真并行自检)、mode 9(PNG 目视)。
|
||||
- `.preflight/perf/pancheck` 7 场景全 `dy=0`;`.preflight/perf/zoomcheck` mode 0/13/14 无回归
|
||||
(f1 8~9ms,缩放收敛 3649 差异=预存在残差)。
|
||||
- `make clean && make all` 零警告。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志(本文件维护记录)
|
||||
@@ -572,6 +710,8 @@ 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` 零警告。 |
|
||||
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -601,7 +741,10 @@ Documents/ 项目文档(含本文件)
|
||||
2. 每网格图层**只含基底**(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例 —— 它们锚定网格左缘、不随时间视界移动)。
|
||||
3. 图层位图宽度 = `width + 2*max(1, width/2)` ≈ **2 × 视界宽**(左右各半屏,可平移 ±半屏)。
|
||||
4. `RenderLaneLayerRegion` 结束后**必须** `BuildLaneVisuals()`(重算 `m_laneContentHeight`)**再**还原 `LaneScroll` 与各网格 `yMin/yMax`;漏还原任何一项 → 算出的图层指纹与实际渲染不符 → 每帧重复重画(白耗性能;旧版有后台线程时会死循环卡死)。
|
||||
5. ⚠️ **图层渲染一律在 UI 线程内同步完成**(`Paint` 全程持有 `m_renderMutex`)。**绝不要把图层渲染搬到后台线程**:它会临时改写 `m_area`/`m_plotRect`/`Tab().ViewMin()/ViewMax()`/各网格矩形,而这些状态被 UI 线程的消息处理**无锁读/写**(平移读视界、命中测试读网格矩形),后台的"保存→改写→恢复"会**覆盖用户正在进行的平移**(视界弹回)并让命中测试错位 —— 这就是 2026-10-12 移除后台图层线程的原因(详见 §12 本轮)。
|
||||
5. ⚠️ **图层渲染默认在 UI 线程内同步完成**,但**多线程渲染「已获用户明确解禁」**(2026-10-13)。要点:
|
||||
- 渲染函数(`RenderLaneLayerRegion` / `PaintLane`)会临时改写 `m_area`/`m_plotRect`/视界/网格矩形,**共享这些状态的并发渲染会互相覆盖**(视界弹回、命中错位)—— 这正是 2026-10-12 移除"后台图层线程"的根因。
|
||||
- **允许并行的唯一安全姿势:每个工作线程持有"隔离的渲染上下文"**。已落地的实现见 `PlotView::RebuildLaneLayersParallel()`:为每条线程准备一个**隔离的 `PlotView`**(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),各自把不同 `laneIndex` 的图层位图渲染到**不同的 `m_layerFront` 元素**(互不重叠),主线程 join 后按**真实网格下标**重算 `LaneLayerKey`。
|
||||
- 实测:并行结果与顺序渲染**逐像素 0 差异**(5 网格全部 0)。⚠️ 但**本机 GDI+ 光栅化会被串行化**(4 线程 CPU 真并行 3.98x,而并行渲染 5 网格仅 **1.03x**)→ 光栅化并行**没有收益**,因此**不要把"逐帧预算 1 个网格"改成"一帧补齐全部"**(那只会把一帧从 ~10ms 抬到 ~31ms)。并行仅用于"本来就要一次画完"的 `RebuildWideBuffer()`。
|
||||
6. 每个图层记录**自己的**时间映射 `LaneLayer::wideMin`;滚动与贴图必须**逐网格**按各自 `wideMin` 计算,**绝不共用一个全局锚点**(否则各网格被平移不同的真实距离 → 网格间时间错位)。补细条时必须传 `layer.wideMin`(不能用"当前视界"的映射,否则有 ≤0.5px 接缝与抖动)。
|
||||
7. 加锁顺序**恒定** `m_renderMutex`(recursive)→ `m_layerMutex`(图层不再有跨线程访问,但顺序约定保留)。
|
||||
8. ⚠️ **前台刷新线程绝不做“整批图层重绘”**:平移/拖动期间 `RollWideBuffer()` **只**做逐网格整数像素平移 + 补细条;内容过期 / 锚点过远的网格只登记到 `m_layerRebuildPending`,由 `RenderPendingLaneRebuilds(budget)` **本帧至多重画 1 个**,其余下帧继续(并 `PostMessage(kMessagePlotDerivedReady)` 请求重绘)。图层没覆盖到的部分 **`BlitWideBuffer` 只贴有像素的那一段**(其余留给底色,下帧补齐),**不得整块跳过**(跳过 = 网格变空 / “断线”)。
|
||||
@@ -610,7 +753,7 @@ Documents/ 项目文档(含本文件)
|
||||
11. `SceneContentHash` 必须覆盖 `Tab().LaneScroll()`(网格纵向滚动量)与 `SelectedRef()`;`FindStyleOnlyLanes()` 必须**逐项比较 `SceneCacheKey`**(`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变")。
|
||||
12. 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)**必须同时**加在"缓存贴图快路径"与"整幅重绘路径"两侧。
|
||||
13. 浮动面板必须按**层序**(`m_panelOrder`,队尾 = 最上层)绘制与命中:命中从后往前取第一个命中者,点击面板 `BringPanelToFront()`,新开面板追加到队尾。**不要再按 `kEdgePanelKinds` 固定顺序命中再 break**(否则上层 ✕ / 标题栏会被下层参数输入框抢走 → “上层面板关不掉 / 拖不动”)。
|
||||
14. **不要为波形构建 / 绘制引入多线程**;已落地的多线程只有派生曲线线程池(`m_curveWorkers`)。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。
|
||||
14. ⚠️ **多线程限制已解禁(2026-10-13),但必须"隔离状态"**:波形渲染可以多线程 —— 前提是每个线程用**自有的渲染上下文**(自有 `m_tab`/`m_area`/`m_plotRect`/`m_lanes`/DC),绝不共享可变渲染状态(见第 5 条的安全姿势)。已落地的并行只有:派生曲线线程池(`m_curveWorkers`)与并行网格图层渲染(`m_layerWorkers`)。⚠️ 实测**光栅化并行无收益**(GDI+ 串行化),所以并行只在"整批重建"这类本来就要一次画完的地方用。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。
|
||||
15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。
|
||||
16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。
|
||||
|
||||
|
||||
Reference in new issue
Block a user