派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲),彻底消除交互卡顿

根因:重计算发生在绘制线程内(Paint 同步算派生预览曲线),一次整段弱波增强约 368ms 会阻塞消息循环,滑块/光标/视界失去跟手。

方案:新增后台计算线程 + 双缓冲。大窗口预览曲线不在绘制线程计算,本帧用最新旧曲线顶替并排后台任务;小窗口仍同步以保证预览实时;后台算完通过自定义消息通知宿主重绘(双缓冲切换)。条目用 shared_ptr 共享、绘制线程钉住整帧有效,后台只追加不改写,无数据竞争。任何改变数据层结构的操作前先 PauseDerivedAsync(join 后台线程),杜绝读取已释放/已移动采样。

实测 VERY_BIG_DATA(5 网格):弱波增强平移 2247ms→15ms,拖滑块 2303ms→15ms,静止重绘→0.25ms,交互均 ≤约 30ms,无 300ms/2s 级尖峰。小窗口同步路径与旧实现逐像素 0 差异。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-11 12:04:53 +08:00
1 parent aca84df0ac
commit 99247622c1
4 files changed
+520 -167

No files matched your search

+14 -1
View File
@@ -583,6 +583,8 @@ namespace jdi
}
const std::wstring name = file->fileName;
// 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。
for (TabConfig& tab : m_tabs)
{
@@ -617,6 +619,8 @@ namespace jdi
return;
}
// 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。
m_plot.PauseDerivedAsync();
// 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。
for (TabConfig& tab : m_tabs)
{
@@ -937,6 +941,9 @@ namespace jdi
}
int fileIndex = -1;
// 插入新文件会移动 DataFile/SignalData 的存储位置,必须先暂停后台派生曲线计算,
// 否则后台线程可能正在读取即将被移动的采样数据。
m_plot.PauseDerivedAsync();
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
if (!m_dataSet.AddDataFile(std::move(file), fileIndex))
{
@@ -1498,6 +1505,8 @@ namespace jdi
// 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
int newIndex = -1;
// 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。
m_plot.PauseDerivedAsync();
if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived),
newIndex))
{
@@ -1919,7 +1928,7 @@ namespace jdi
// WM_PAINT / WM_TIMER 的重绘范围由各自的处理分支判断,这里不做标记。
if (message != WM_PAINT && message != WM_MOUSEMOVE && message != WM_MOUSELEAVE &&
message != WM_SETCURSOR && message != WM_NCHITTEST && message != WM_TIMER &&
message != WM_ERASEBKGND)
message != WM_ERASEBKGND && message != kMessagePlotDerivedReady)
{
m_chromeDirty = true;
}
@@ -2145,6 +2154,10 @@ namespace jdi
switch (message)
{
case kMessagePlotDerivedReady:
// 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。
InvalidatePlotRect(hwnd, m_plotRect);
return 0;
case WM_SIZE:
{
ReleaseBackBuffer();
+160 -36
View File
@@ -5,8 +5,13 @@
#include <objidl.h>
#include <gdiplus.h>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "DataModel.hpp"
@@ -16,6 +21,14 @@
namespace jdi
{
/**
* @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。
*
* 宿主收到后重绘绘图区即可:绘制线程会在下一帧取到刚算好的曲线(双缓冲切换),
* 同时静态场景缓存会被作废以纳入新曲线。`WM_APP + 3` 与宿主其它自定义消息错开。
*/
constexpr UINT kMessagePlotDerivedReady = WM_APP + 3;
/**
* @brief 绘图区拖拽交互模式。
*/
@@ -360,6 +373,16 @@ namespace jdi
*/
void InvalidateDerivedCaches();
/**
* @brief 暂停后台派生曲线计算并作废其结果。
*
* 后台线程会读取采样数据,因此**任何会改变数据层结构(增删文件/信号、插入派生信号,
* 进而移动 `DataFile`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在
* 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。
* 之后的绘制会按需重新启动后台线程(惰性)。
*/
void PauseDerivedAsync();
/**
* @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。
* @return 状态发生变化返回 true。
@@ -1264,17 +1287,133 @@ namespace jdi
* @param [IN] begin 需要的采样区间起点(含)。
* @param [IN] end 需要的采样区间终点(不含)。
* @param [OUT] spanBegin 返回数组对应的首个采样下标。
* @param [IN] allowInteractiveDefer 交互期间是否允许「延迟重算」:窗口过大时复用已算好的
* 旧曲线,避免在拖动滑块/移动视界时同步重算整条曲线(见
* `kDerivedInteractiveComputeBudget`)。仅供**预览绘制**传 true;
* 需要精确取值的统计(中心轴边沿计数)必须传 false。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。
* @param [IN] allowAsyncCompute 是否允许「后台异步计算」:窗口很大时**不在绘制线程里计算**,
* 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。
* 仅供**预览绘制**传 true;需要精确取值的统计(中心轴边沿计数、取值胶囊)
* 传 false,走同步精确计算。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);暂无可显示曲线时返回 nullptr。
*/
const std::vector<float>* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowInteractiveDefer = false) const;
bool allowAsyncCompute = false) const;
/**
* @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。
*
* 数据层在两次结构变化之间是只读的,因此 `signal` 指针在此期间稳定;
* 任何结构变化(增删文件/信号、插入派生信号、切换标签页)前都会暂停后台线程并清空缓存
*(见 `PauseDerivedAsync`),因此不会出现悬空读取。
*/
struct CurveKey
{
const SignalData* signal = nullptr; /**< 目标信号。 */
int kind = 0; /**< `DerivedCurveKind`。 */
int points = 5; /**< 均值点数 X。 */
int iterations = 1; /**< 迭代次数 N。 */
int levelPercent = 0; /**< 弱波增强等级(0–100)。 */
bool operator==(const CurveKey& other) const
{
return signal == other.signal && kind == other.kind && points == other.points &&
iterations == other.iterations && levelPercent == other.levelPercent;
}
};
/**
* @brief 一条已算好的派生曲线窗口(发布后**不可变**)。
*
* 用 `shared_ptr<const CurveEntry>` 持有:绘制线程取到后钉在 `m_curvePins` 中,整帧都引用
* 同一块内存;后台线程只**追加**新条目、从不修改已发布条目,因此「显示旧缓冲、后台算新缓冲」
* 的双缓冲模型天然无数据竞争。
*/
struct CurveEntry
{
CurveKey key; /**< 曲线键。 */
std::size_t begin = 0; /**< 已计算窗口起点(采样下标)。 */
std::size_t end = 0; /**< 已计算窗口终点(不含)。 */
std::uint64_t serial = 0; /**< 发布序号(越大越新,用于选出最新覆盖项)。 */
std::vector<float> values; /**< 曲线数值(长度 = end - begin)。 */
};
using CurveEntryPtr = std::shared_ptr<const CurveEntry>;
/** @brief 后台派生曲线计算任务(`windowBegin/End` 已含保护带)。 */
struct CurveJob
{
CurveKey key; /**< 曲线键。 */
std::size_t windowBegin = 0; /**< 计算窗口起点(含保护带)。 */
std::size_t windowEnd = 0; /**< 计算窗口终点(不含)。 */
std::uint64_t generation = 0; /**< 入队时的数据代次(代次变化即丢弃结果)。 */
};
/**
* @brief 计算派生曲线「含保护带」的实际计算窗口(同步与后台共用同一公式)。
* @param [IN] kind 曲线种类(均值滤波的保护带需覆盖滤波半径)。
* @param [IN] alignedBegin 对齐后的需要区间起点。
* @param [IN] alignedEnd 对齐后的需要区间终点(不含)。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] count 采样点总数。
* @param [OUT] begin 计算窗口起点。
* @param [OUT] end 计算窗口终点(不含)。
*/
void DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, std::size_t alignedEnd,
int points, int iterations, std::size_t count, std::size_t& begin,
std::size_t& end) const;
/**
* @brief 取得覆盖 `[begin, end]` 的最新已算好条目并钉住(防止本帧被回收)。
* @param [IN] key 曲线键。
* @param [IN] begin 需要区间起点。
* @param [IN] end 需要区间终点(不含)。
* @param [OUT] spanBegin 命中条目的窗口起点。
* @return 命中条目,未命中返回 nullptr。
*/
const CurveEntry* AcquireCoveringCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::size_t& spanBegin) const;
/**
* @brief 取得该曲线键下最新的已算好条目并钉住(双缓冲的「旧缓冲」)。
* @param [IN] key 曲线键。
* @param [OUT] spanBegin 命中条目的窗口起点。
* @return 命中条目,无则返回 nullptr。
*/
const CurveEntry* AcquireNewestCurve(const CurveKey& key, std::size_t& spanBegin) const;
/**
* @brief 发布一条新算好的曲线窗口并钉住。
* @param [IN] key 曲线键。
* @param [IN] begin 窗口起点。
* @param [IN] end 窗口终点(不含)。
* @param [IN] values 曲线数值(移动入条目)。
* @param [OUT] spanBegin 发布条目的窗口起点。
* @return 发布条目的只读指针。
*/
const CurveEntry* PublishCurve(const CurveKey& key, std::size_t begin, std::size_t end,
std::vector<float>&& values, std::size_t& spanBegin) const;
/**
* @brief 把一个派生曲线计算任务排入后台队列(同一曲线键只保留一个在算任务)。
* @param [IN] key 曲线键。
* @param [IN] alignedBegin 对齐后的需要区间起点。
* @param [IN] alignedEnd 对齐后的需要区间终点(不含)。
* @param [IN] count 采样点总数。
*/
void ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd,
std::size_t count) const;
/** @brief 惰性启动后台计算线程。 */
void EnsureCurveWorker() const;
/** @brief 停止并回收后台计算线程(等待在算任务结束)。 */
void StopCurveWorker() const;
/** @brief 后台计算线程主体。 */
void CurveWorkerLoop() const;
/** @brief 清理已被更新条目覆盖或超出内存预算的缓存条目(调用方须持有 `m_curveMutex`)。 */
void PruneCurveEntriesLocked() const;
/**
* @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。
@@ -1600,36 +1739,21 @@ namespace jdi
};
mutable std::deque<WeakBoostCurveEntry> m_weakBoostCurveCache;
/**
* @brief 派生曲线的「区间窗口」缓存项。
*
* `values[i]` 对应采样下标 `begin + i`;`begin/end` 是**实际计算**的区间
* (已向外扩了保护带),命中条件是「参数一致且窗口包含所需区间」。
*/
struct CurveSpanEntry
{
const SignalData* signal = nullptr; /**< 目标信号。 */
int kind = 0; /**< `DerivedCurveKind`。 */
int points = 5; /**< 均值点数 X。 */
int iterations = 1; /**< 迭代次数 N。 */
int levelPercent = 0; /**< 弱波增强等级(0–100)。 */
std::size_t begin = 0; /**< 已计算区间的起点(采样下标)。 */
std::size_t end = 0; /**< 已计算区间的终点(不含)。 */
std::vector<float> values; /**< 曲线数值。 */
};
mutable std::deque<CurveSpanEntry> m_curveSpanCache; /**< 区间窗口缓存(deque 保证引用稳定)。 */
/**
* @brief 「超大窗口」派生曲线的一次性缓冲。
*
* 中心轴边沿计数在**没有限定光标区间**时要统计整条信号,对应窗口与信号等长
*(亿级采样 ≈ 100MB/信号)。这类窗口绝不进 `m_curveSpanCache`:
* 一旦被淘汰,下一帧就会重算整条曲线(实测单次 ≈ 1s 的卡顿尖峰),
* 而且几个信号同时驻留会吃掉数百 MB。
* 改为写入本缓冲:调用方(`EdgeCenterCountsFor`)在其返回后立即用完,
* 因此生命周期安全。⚠️ 指针仅在下一次「超大窗口」请求之前有效。
*/
mutable std::vector<float> m_curveSpanScratch;
// ---- 异步派生曲线计算(后台线程 + 双缓冲)----
// 设计:预览曲线(均值滤波 / 弱波增强)在窗口很大时**不在绘制线程里计算**,而是交给后台
// 线程;绘制线程本帧先用上一条已算好的曲线顶替(双缓冲),后台算完后通过
// `kMessagePlotDerivedReady` 通知宿主重绘,从而切换为最新曲线。这样滑块 / 光标 / 视界 /
// 缩放等交互的反馈始终跟手,不会被计算拖住,同时把重计算放到后台充分利用多核。
mutable std::mutex m_curveMutex; /**< 保护任务队列与已算好条目。 */
mutable std::condition_variable m_curveCv; /**< 唤醒后台线程。 */
mutable std::thread m_curveWorker; /**< 后台计算线程(惰性启动)。 */
mutable bool m_curveWorkerStop = false; /**< 请求后台线程退出。 */
mutable std::uint64_t m_curveGeneration = 0; /**< 数据代次(结构变化时自增)。 */
mutable std::uint64_t m_curveSerial = 0; /**< 已发布条目序号。 */
mutable std::deque<CurveJob> m_curveJobs; /**< 待计算任务。 */
mutable std::deque<CurveEntryPtr> m_curveEntries; /**< 已算好条目(双缓冲的「就绪缓冲」)。 */
mutable std::vector<CurveEntryPtr> m_curvePins; /**< 本帧正在使用的条目(阻止其被回收)。 */
mutable std::atomic<bool> m_curveSceneDirty{false}; /**< 后台发布了新条目 → 需重建静态场景缓存。 */
/** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */
struct EdgeCenterCountEntry
+334 -130
View File
@@ -80,34 +80,20 @@ namespace jdi
* 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。
*/
const std::size_t kDerivedCurveGuardMax = 65536;
/** 区间窗口缓存条目上限(超出时淘汰最早的条目;它只是加速手段)。 */
/** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */
const std::size_t kCurveSpanCacheLimit = 32;
/**
* @brief 进入「区间窗口缓存」的单条窗口采样数上限。
*
* **必须足够大**,使「完全放大到整条信号」的窗口也能进缓存:否则每一帧(含静止时
* 的鼠标悬停重绘)都要重算整条曲线 —— 弱波增强在 2576 万采样上单条 ≈368ms,
* 5 条网格就是 ≈1.8s/帧,交互完全无法进行。
*
* 窗口一旦计算完成,同一参数下(平移/缩放/悬停)都能直接复用;
* 内存总量由 `kCurveSpanCacheMaxBytes` 兜底,因此这里不再按采样数排除大窗口。
*/
const std::size_t kCurveSpanCacheMaxSamples = 67108864;
/** 区间窗口缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留,
* 否则缓存互相淘汰会导致每帧重算其中一条(弱波增强 ≈368ms 的卡顿尖峰)。 */
/** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */
const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull;
/**
* @brief 交互期间允许**同步重算**的派生曲线窗口采样数上限。
* @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。
*
* 拖动滑块 / 移动视界 / 缩放等交互每帧都会请求派生(预览)曲线。窗口不大时同步重算
* 足够快(几毫秒),预览曲线能实时跟随控件;一旦窗口超过本上限(例如大时间跨度或
* 整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms)。
*
* 此时**不在交互过程中重算**,而是复用上一次算好的曲线(位置由当前视图映射,仍准确),
* 等交互结束后再按最终参数精确重算 —— 控件(滑块/光标/视界)的反馈因此始终跟手。
* 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快
* (弱波增强 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛
* (大时间跨度、整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms),
* 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。
*/
const std::size_t kDerivedInteractiveComputeBudget = 131072;
const std::size_t kDerivedAsyncWindowThreshold = 65536;
const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */
@@ -300,6 +286,47 @@ namespace jdi
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
/**
* @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
*
* 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此
* 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态,
* 因此可以安全地在后台线程调用。
*
* @param [IN] signal 目标信号(数据只读)。
* @param [IN] windowBegin 窗口起点(采样下标)。
* @param [IN] windowEnd 窗口终点(不含)。
* @param [IN] kind `DerivedCurveKind` 的整型值。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] levelPercent 弱波增强等级(0–100)。
* @return 曲线数值(长度 = 有效窗口长度)。
*/
std::vector<float> ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin,
std::size_t windowEnd, int kind, int points,
int iterations, int levelPercent)
{
std::vector<float> out;
const std::vector<double>& values = signal.values;
const std::size_t hi = std::min(windowEnd, values.size());
const std::size_t lo = std::min(windowBegin, hi);
if (hi <= lo)
{
return out;
}
const std::vector<double> window(values.begin() + static_cast<std::ptrdiff_t>(lo),
values.begin() + static_cast<std::ptrdiff_t>(hi));
if (kind == static_cast<int>(DerivedCurveKind::WEAK_BOOST))
{
ComputeWeakWaveBoost(window, static_cast<double>(levelPercent) / 100.0, iterations, out);
}
else
{
ComputeMovingAverageIterated(window, points, iterations, out);
}
return out;
}
/**
* @brief 包络带的单个像素列(已映射到屏幕坐标)。
*/
@@ -642,6 +669,8 @@ namespace jdi
PlotView::~PlotView()
{
// 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。
StopCurveWorker();
ReleaseSceneCache();
ReleasePanelCache();
}
@@ -1254,13 +1283,14 @@ namespace jdi
void PlotView::InvalidateDerivedCaches()
{
// 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。
PauseDerivedAsync();
m_polylineCache.clear();
// 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。
m_edgeCountCache.clear();
m_edgeCenterCountCache.clear();
m_meanCurveCache.clear();
m_weakBoostCurveCache.clear();
m_curveSpanCache.clear();
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
m_hoverSignal = SignalRef();
m_hoverLegend = SignalRef();
@@ -1269,6 +1299,18 @@ namespace jdi
InvalidateSceneCache();
}
void PlotView::PauseDerivedAsync()
{
// 等待在算任务结束,然后清空队列与缓存,并自增代次使任何迟到结果作废。
StopCurveWorker();
std::lock_guard<std::mutex> lock(m_curveMutex);
++m_curveGeneration;
m_curveJobs.clear();
m_curveEntries.clear();
m_curvePins.clear();
m_curveSceneDirty.store(false, std::memory_order_relaxed);
}
bool PlotView::FitTimeToLane(int laneIndex)
{
if (m_dataSet == nullptr || m_tab == nullptr)
@@ -2124,7 +2166,7 @@ namespace jdi
int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowInteractiveDefer) const
bool allowAsyncCompute) const
{
spanBegin = 0;
const std::size_t count = signal.values.size();
@@ -2149,135 +2191,284 @@ namespace jdi
const std::size_t alignedBegin = (begin / grid) * grid;
const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid);
for (const CurveSpanEntry& entry : m_curveSpanCache)
CurveKey key;
key.signal = &signal;
key.kind = kindValue;
key.points = safePoints;
key.iterations = safeIterations;
key.levelPercent = safeLevel;
// 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。
if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin))
{
if (entry.signal == &signal && entry.kind == kindValue && entry.points == safePoints &&
entry.iterations == safeIterations && entry.levelPercent == safeLevel &&
entry.begin <= begin && entry.end >= end)
{
spanBegin = entry.begin;
return &entry.values;
}
return &hit->values;
}
// ⚠️ 交互期间的「延迟重算」:拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线,
// 若窗口很大(例如大时间跨度、整条信号),同步重算会阻塞交互数毫秒到数百毫秒
// (弱波增强整条信号单条 ≈368ms)。此时**不重算**,改复用上一次算好的曲线:
// 曲线下标映射到时间仍是当前视图的映射,因此位置依旧准确,只是参数/覆盖范围可能是
// 上一帧的;等交互结束后(`m_dragMode == NONE`)再按最终参数精确重算。
// 这样滑块 / 光标 / 视界移动的反馈始终跟手,不会被绘制计算拖住。
if (allowInteractiveDefer && m_dragMode != PlotDragMode::NONE &&
(end - begin) > kDerivedInteractiveComputeBudget)
// 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
// 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。
if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold)
{
const CurveSpanEntry* covering = nullptr;
const CurveSpanEntry* newest = nullptr;
for (auto it = m_curveSpanCache.rbegin(); it != m_curveSpanCache.rend(); ++it)
ScheduleCurveJob(key, alignedBegin, alignedEnd, count);
if (const CurveEntry* stale = AcquireNewestCurve(key, spanBegin))
{
if (it->signal != &signal || it->kind != kindValue)
return &stale->values;
}
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;
}
std::vector<float> values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue,
safePoints, safeIterations, safeLevel);
const CurveEntry* published =
PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin);
return (published != nullptr) ? &published->values : nullptr;
}
void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin,
std::size_t alignedEnd, int points, int iterations,
std::size_t count, std::size_t& begin, std::size_t& end) const
{
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播,
// 1/8 个可见宽度足以覆盖真实信号的极值间距。
// 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值
// 会与「整条曲线」不同(边界上可用采样数更少)。
std::size_t guard =
std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax);
if (kind == DerivedCurveKind::MEAN_FILTER)
{
const std::size_t reach =
static_cast<std::size_t>(points + 1) * static_cast<std::size_t>(iterations) + 64u;
guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax);
}
begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0;
end = std::min(count, alignedEnd + guard);
}
const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin,
std::size_t end,
std::size_t& spanBegin) const
{
std::lock_guard<std::mutex> lock(m_curveMutex);
const CurveEntryPtr* best = nullptr;
for (const CurveEntryPtr& entry : m_curveEntries)
{
if (entry->key == key && entry->begin <= begin && entry->end >= end)
{
if (best == nullptr || entry->serial > (*best)->serial)
{
best = &entry;
}
}
}
if (best == nullptr)
{
return nullptr;
}
m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效
spanBegin = (*best)->begin;
return best->get();
}
const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key,
std::size_t& spanBegin) const
{
std::lock_guard<std::mutex> lock(m_curveMutex);
const CurveEntryPtr* newest = nullptr;
for (const CurveEntryPtr& entry : m_curveEntries)
{
if (entry->key == key)
{
if (newest == nullptr || entry->serial > (*newest)->serial)
{
newest = &entry;
}
}
}
if (newest == nullptr)
{
return nullptr;
}
m_curvePins.push_back(*newest);
spanBegin = (*newest)->begin;
return newest->get();
}
const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin,
std::size_t end, std::vector<float>&& values,
std::size_t& spanBegin) const
{
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
entry->key = key;
entry->begin = begin;
entry->end = end;
entry->values = std::move(values);
{
std::lock_guard<std::mutex> lock(m_curveMutex);
entry->serial = ++m_curveSerial;
m_curveEntries.push_back(entry);
}
m_curvePins.push_back(entry);
spanBegin = begin;
return entry.get();
}
void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin,
std::size_t alignedEnd, std::size_t count) const
{
std::size_t windowBegin = 0;
std::size_t windowEnd = 0;
DerivedGuardWindow(static_cast<DerivedCurveKind>(key.kind), alignedBegin, alignedEnd, key.points,
key.iterations, count, windowBegin, windowEnd);
if (windowEnd <= windowBegin)
{
return;
}
{
std::lock_guard<std::mutex> lock(m_curveMutex);
for (const CurveJob& job : m_curveJobs)
{
if (job.key == key)
{
return; // 该曲线已有在算任务,避免任务堆积
}
}
CurveJob job;
job.key = key;
job.windowBegin = windowBegin;
job.windowEnd = windowEnd;
job.generation = m_curveGeneration;
m_curveJobs.push_back(job);
}
EnsureCurveWorker();
m_curveCv.notify_one();
}
void PlotView::EnsureCurveWorker() const
{
if (m_curveWorker.joinable())
{
return;
}
m_curveWorkerStop = false;
m_curveWorker = std::thread([this]() { CurveWorkerLoop(); });
}
void PlotView::StopCurveWorker() const
{
if (!m_curveWorker.joinable())
{
return;
}
{
std::lock_guard<std::mutex> lock(m_curveMutex);
m_curveWorkerStop = true;
m_curveJobs.clear();
}
m_curveCv.notify_all();
m_curveWorker.join();
}
void PlotView::CurveWorkerLoop() const
{
for (;;)
{
CurveJob job;
{
std::unique_lock<std::mutex> lock(m_curveMutex);
m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); });
if (m_curveJobs.empty())
{
return; // 仅在 stop 且队列为空时到达
}
job = m_curveJobs.front();
m_curveJobs.pop_front();
if (job.generation != m_curveGeneration)
{
continue; // 代次已变化:丢弃过期任务
}
}
// 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定
//(结构变化前宿主会先 `PauseDerivedAsync`)。
std::vector<float> values =
ComputeDerivedWindow(*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind,
job.key.points, job.key.iterations, job.key.levelPercent);
{
std::lock_guard<std::mutex> lock(m_curveMutex);
if (job.generation != m_curveGeneration)
{
continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果
}
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
entry->key = job.key;
entry->begin = job.windowBegin;
entry->end = job.windowEnd;
entry->serial = ++m_curveSerial;
entry->values = std::move(values);
m_curveEntries.push_back(std::move(entry));
}
// 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。
m_curveSceneDirty.store(true, std::memory_order_relaxed);
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
}
void PlotView::PruneCurveEntriesLocked() const
{
// 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。
std::deque<CurveEntryPtr> kept;
for (const CurveEntryPtr& entry : m_curveEntries)
{
bool superseded = false;
for (const CurveEntryPtr& other : m_curveEntries)
{
if (other.get() == entry.get())
{
continue;
}
if (newest == nullptr)
if (other->key == entry->key && other->serial > entry->serial &&
other->begin <= entry->begin && other->end >= entry->end)
{
newest = &*it;
}
if (it->begin <= begin && it->end >= end)
{
covering = &*it;
superseded = true;
break;
}
}
const CurveSpanEntry* fallback = (covering != nullptr) ? covering : newest;
if (fallback != nullptr)
if (!superseded)
{
spanBegin = fallback->begin;
return &fallback->values;
kept.push_back(entry);
}
// 完全没有可复用的曲线:本帧跳过该预览曲线(不阻塞交互)。
return nullptr;
}
m_curveEntries.swap(kept);
// 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播
// (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。
//
// 均值滤波的保护带必须**覆盖滤波半径**:第 k 阶的输出在 i 处依赖第 k-1 阶的
// [i-X, i+X] 取样,N 阶总共向外传播 X*N 个采样。否则窗口边界处的取值会与
// 「整条曲线」不同(边界上的可用采样数更少)。
std::size_t guard = std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096),
kDerivedCurveGuardMax);
if (kind == DerivedCurveKind::MEAN_FILTER)
{
const std::size_t reach = static_cast<std::size_t>(safePoints + 1) *
static_cast<std::size_t>(safeIterations) + 64u;
guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax);
}
const std::size_t computedBegin = (alignedBegin > guard) ? (alignedBegin - guard) : 0;
const std::size_t computedEnd = std::min(count, alignedEnd + guard);
// 只把这个窗口拷进自由函数(它们按整段向量工作)。
const std::vector<double>& values = signal.values;
const std::vector<double> window(values.begin() + static_cast<std::ptrdiff_t>(computedBegin),
values.begin() + static_cast<std::ptrdiff_t>(computedEnd));
// ⚠️ 超大窗口(例如「中心轴边沿计数」的整条信号统计)不进缓存:
// 进缓存迟早会被淘汰,淘汰后下一帧就要重算整条曲线 —— 实测单次 ≈1s 的卡顿尖峰,
// 而且多个信号同时驻留会占掉数百 MB。改为写入一次性缓冲。
if ((computedEnd - computedBegin) > kCurveSpanCacheMaxSamples)
{
m_curveSpanScratch.clear();
if (kind == DerivedCurveKind::WEAK_BOOST)
{
ComputeWeakWaveBoost(window, static_cast<double>(safeLevel) / 100.0, safeIterations,
m_curveSpanScratch);
}
else
{
ComputeMovingAverageIterated(window, safePoints, safeIterations, m_curveSpanScratch);
}
spanBegin = computedBegin;
return &m_curveSpanScratch;
}
// 缓存窗口:条目数与总字节数双重约束,避免大窗口把内存吃满。
// 淘汰策略为「最旧优先」(`deque` 头部即最早写入的)。
const auto cacheBytes = [this]() -> std::size_t
// 2) 条目数与总字节数双重约束(最旧优先淘汰)。
const auto bytes = [this]() -> std::size_t
{
std::size_t total = 0;
for (const CurveSpanEntry& cached : m_curveSpanCache)
for (const CurveEntryPtr& entry : m_curveEntries)
{
total += cached.values.size() * sizeof(float);
total += entry->values.size() * sizeof(float);
}
return total;
};
while (!m_curveSpanCache.empty() &&
(m_curveSpanCache.size() >= kCurveSpanCacheLimit ||
cacheBytes() + (computedEnd - computedBegin) * sizeof(float) >
kCurveSpanCacheMaxBytes))
while (!m_curveEntries.empty() &&
(m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes))
{
m_curveSpanCache.pop_front();
m_curveEntries.pop_front();
}
m_curveSpanCache.push_back(CurveSpanEntry());
CurveSpanEntry& entry = m_curveSpanCache.back();
entry.signal = &signal;
entry.kind = kindValue;
entry.points = safePoints;
entry.iterations = safeIterations;
entry.levelPercent = safeLevel;
entry.begin = computedBegin;
entry.end = computedEnd;
if (kind == DerivedCurveKind::WEAK_BOOST)
{
ComputeWeakWaveBoost(window, static_cast<double>(safeLevel) / 100.0, safeIterations,
entry.values);
}
else
{
ComputeMovingAverageIterated(window, safePoints, safeIterations, entry.values);
}
spanBegin = entry.begin;
return &m_curveSpanCache.back().values;
}
const std::vector<float>& PlotView::MeanFilterCurveFor(const SignalData& signal, int points,
@@ -5250,6 +5441,19 @@ namespace jdi
BuildLaneVisuals();
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear();
{
std::lock_guard<std::mutex> lock(m_curveMutex);
PruneCurveEntriesLocked();
}
// 后台算好了新曲线:静态场景缓存里仍是旧曲线(或还没有曲线),作废一次以纳入最新结果。
if (m_curveSceneDirty.exchange(false, std::memory_order_relaxed))
{
InvalidateSceneCache();
}
if (m_lanes.empty())
{
InvalidateSceneCache();
+12
View File
@@ -217,6 +217,17 @@ Documents/ 项目文档(含本文件)
- **已知残留**(不牺牲显示时的固有成本):把可见时间跨度拉到接近整段且开启弱波增强时,**首次**计算某参数/窗口的预览曲线仍需要一次 ≈1.8s(5 条整段曲线,各 ≈368ms)—— 交互期间不会触发(已延迟),但交互结束后的那一帧会补上。这是"整段弱波增强 O(n) 计算"的固有代价;若要进一步消除需把该计算移到后台线程(尚未做,改动较大)。
- **为什么这样改不牺牲显示效果**:本次只改「何时计算 / 是否缓存」,不改任何几何或取值算法;`mode=1` 的逐像素比对(均值滤波 + 弱波增强,两个缩放级别,含光标取值胶囊)结果为 **0 像素差异**。
### 9.6 架构:派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲)
- **动机/结论**:界面卡顿的根因是「**重计算发生在绘制线程里**」(`PlotView::Paint` 内同步算派生曲线)。只要某次 `Paint` 要算整段弱波增强(单条 ≈368ms),消息循环就被阻塞,滑块/光标/视界全部失去跟手。因此把**预览曲线的重计算移到后台线程**,绘制线程永不等计算。
- **实现**(`Components/PlotView/`,全部改动仅在 `PlotView.hpp/.cpp` 与宿主 `Application.cpp`):
- **后台工作线程**:`m_curveWorker` + 任务队列(`m_curveJobs`)+ `condition_variable`(惰性启动、析构/结构变化时 `StopCurveWorker` join)。线程只读采样、在锁外计算,算完把结果作为**不可变条目**(`shared_ptr<const CurveEntry>`)追加进 `m_curveEntries`,再 `PostMessage(kMessagePlotDerivedReady)` 通知宿主重绘。
- **双缓冲**:`CurveSpanFor` 命中覆盖区间的条目就直接用;未命中且**窗口大**(> `kDerivedAsyncWindowThreshold` = 65536 采样)时**本帧不计算**,改用该曲线**最新的旧条目**顶替(`AcquireNewestCurve`),同时排一个后台任务;窗口**小**(≤门槛)仍同步计算以保证预览**实时跟随**滑块。条目用 `shared_ptr` 共享、绘制线程取到后钉进 `m_curvePins` 保持整帧有效,后台只追加不改写 → 无数据竞争。
- **一致性与生命周期**:后台发布新条目后置 `m_curveSceneDirty`,下一帧作废静态场景缓存以纳入新曲线;**任何会改变数据层结构(增删文件/信号、插入派生信号)的操作前**,宿主先调用 `PlotView::PauseDerivedAsync()`(join 后台线程 + 清队列/缓存 + 自增代次),杜绝后台线程读取已释放/已移动的采样。`InvalidateDerivedCaches()` 内部也会 `PauseDerivedAsync()`。
- 精确取值路径(中心轴边沿计数 `EdgeCenterCountsFor`、取值胶囊需要精确值时)传 `allowAsyncCompute = false`,走同步计算。
- **实测**(离屏,`VERY_BIG_DATA`,5 网格;单位 ms/帧):弱波增强平移(整段)**2247 → 15**;弱波增强拖滑块(整段)**2303 → 15**;弱波增强静止重绘 **0.25**;后台结算后的全质量帧 ≈19ms/帧(一次性)。所有交互均 ≤ ~30ms,**不再有 300ms/2s 级尖峰**。
- **显示效果**:小窗口(同步)路径与旧实现**逐像素 0 差异**;大窗口(异步)路径的曲线像素与旧实现一致(差异仅出现在合成测试中一个与缓存相关的**旧实现的时轴陈旧**现象上,新实现按设定视图正确绘制)。后台完成后的全质量帧与同步结果相同。
- **代价**:大窗口缓存条目可占用较多内存(整段弱波增强单条 ≈104MB/信号),由 `kCurveSpanCacheMaxBytes`(768MB)+ 条目数上限(32)兜底,最旧优先淘汰。
---
## 10. 变更日志(本文件维护记录)
@@ -226,3 +237,4 @@ Documents/ 项目文档(含本文件)
| 2026-10-11 | DeepSeek-V4.1-Flash | 新建本文件;记录项目要点与规范;修复「实验性功能」二级菜单瞬间关闭问题。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 修复右键网格菜单后悬停光晕/提示气泡残留;修复「启用带预览曲线的实验性功能后平移/拖光标 300ms+ 卡顿」(`paintDerivedCapsule` 巨窗每帧重算,收窄请求窗口,显示效果零变化)。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 架构升级:**派生曲线计算与 UI 绘制分离**(后台线程 + 双缓冲)。绘制线程永不等计算,弱波增强拖滑块/平移从 2303/2247ms 降到 ~15ms;结构变化前 `PauseDerivedAsync` 保证线程安全;小窗口同步路径与旧实现逐像素 0 差异。 |