优化百万级采样文件的绘制性能,Esc 改为取消拖拽选区

性能(BIG_DATA.csv 128 万采样 × 10 信号全显示,实测):
- 折线抽稀改为按像素列取上下包络(min/max),顶点数由固定 20000 降到像素量级;
  既保住尖峰(等间隔抽样会整段漏掉),又不再把大量顶点堆在同一像素列上。
- 新增折线顶点缓存:以“构建折线实际读取的全部输入”(信号、时间轴范围、绘图区横向
  几何、网格纵向几何)为键,视图未变化时直接复用,重复绘制不再重算全量采样。
- 悬停命中检测先按 x 二分裁剪到容差范围内的一小段(点到线段距离必不小于横向距离),
  鼠标移动不再遍历上万个顶点。
- 结果:整帧渲染 1309ms -> 103~127ms(10 网格);折线构建 25ms -> 0ms(缓存命中);
  单条折线绘制 1255ms -> 45~53ms;悬停命中 ~0ms。两文件 6 网格场景约 52ms。
- 未引入多线程:耗时集中在 GDI+ 单线程绘制调用与一次性 O(N) 包络扫描,
  并行化的收益上限仅约 25ms,不值得引入线程与同步复杂度。
- 空闲(无交互)CPU 占用实测 0.00%,消息循环阻塞在 GetMessage,
  无需重绘时不再产生任何计算。

Esc 行为:
- 缩放拖拽(时间轴/幅值轴框选)按住左键时按 Esc:立即放弃选区并清空拖拽状态,
  随后松开左键也不会把选区应用到视图(已验证:时间轴保持原范围,对照组正常缩放)。
- Esc 不再关闭时间光标(原先 Esc 会调用 DisableCursor);关闭光标只走工具栏「光标」按钮,
  避免顺手按 Esc 时意外关掉指针。
- 数据层结构变化(加载/移除文件、切换上下文)后清空折线缓存,
  防止旧几何键与新信号重合而取到过期顶点。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-30 17:49:36 +08:00
1 parent 12b3531485
commit d6a81839bb
3 files changed
+312 -20

No files matched your search

+50
View File
@@ -5,6 +5,7 @@
#include <objidl.h>
#include <gdiplus.h>
#include <deque>
#include <string>
#include <vector>
@@ -229,6 +230,18 @@ namespace jdi
void DisableCursor();
/**
* @brief 放弃当前正在进行的拖拽(缩放选区/平移/信号拖动),不改变视图范围。
* @return 确实取消了某个拖拽返回 true。
*/
bool CancelDrag();
/**
* @brief 清空折线顶点缓存。
*
* 数据层结构变化(加载/移除文件、信号增删)后信号引用与文件地址都可能改变,
* 必须清空缓存,避免旧几何键与新的信号意外重合而取到过期顶点。
*/
void InvalidatePolylineCache(); /**
* @brief 切换时间光标:启用时置于当前视图中心,再调用则关闭。
* @return 状态发生变化返回 true。
*/
@@ -742,6 +755,43 @@ namespace jdi
std::vector<LegendHit> m_legendHits; /**< 最近一次绘制时记录的图例条目矩形。 */
/**
* @brief 折线顶点缓存条目。
*
* 折线抽稀是 O(采样点数) 的(百万级采样约 2~3ms/条),
* 而绘制、悬停命中检测每帧都要用到同一批顶点。
* 用“构建折线时读取的全部输入”作为键缓存顶点,
* 视图/网格几何未变化时可直接复用,避免重复扫描全量采样。
*/
struct PolylineCacheEntry
{
SignalRef ref; /**< 信号引用。 */
const DataFile* file = nullptr; /**< 所属文件(内容不可变)。 */
std::size_t sampleCount = 0; /**< 采样点数(数据变化时改变)。 */
double viewMin = 0.0; /**< 时间轴显示下界。 */
double viewMax = 0.0; /**< 时间轴显示上界。 */
float plotX = 0.0f; /**< 绘图区左边界。 */
float plotWidth = 0.0f; /**< 绘图区宽度。 */
float laneY = 0.0f; /**< 网格上边界。 */
float laneHeight = 0.0f; /**< 网格高度。 */
double laneYMin = 0.0; /**< 网格幅值下界。 */
double laneYMax = 0.0; /**< 网格幅值上界。 */
std::vector<gd::PointF> points; /**< 折线顶点。 */
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
};
/**
* @brief 取得(必要时重建)指定信号在当前几何下的折线顶点。
* @param [IN] ref 信号引用(缓存键的一部分)。
* @param [IN] resolved 已解析的信号。
* @param [IN] lane 网格绘制信息。
* @return 缓存条目指针,无法构建时返回 nullptr。
*/
const PolylineCacheEntry* AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved,
const LaneVisual& lane) const;
mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */
/**
* @brief 查询坐标命中的图例条目。
* @param [IN] x 客户区横坐标。
+254 -18
View File
@@ -21,8 +21,12 @@ namespace jdi
const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */
const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
const float kLanePadding = 8.0f; /**< 网格内边距。 */
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数。 */
const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */
const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */
const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */
const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */
const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */
@@ -103,6 +107,8 @@ namespace jdi
m_tab = tab;
m_hoverLane = -1;
m_dragMode = PlotDragMode::NONE;
// 重新绑定数据源后旧的几何键不再有意义,必须清空折线缓存。
InvalidatePolylineCache();
RefreshLanes();
}
@@ -503,6 +509,30 @@ namespace jdi
}
}
bool PlotView::CancelDrag()
{
if (m_dragMode == PlotDragMode::NONE)
{
return false;
}
// 放弃当前拖拽:清空拖拽状态与尚未生效的缩放选区,不改动任何视图范围。
// 调用方仍持有鼠标捕获,松开左键时会走 OnLButtonUp,
// 此时拖拽模式已是 NONE,选区不会被应用到时间轴/幅值轴上。
m_dragMode = PlotDragMode::NONE;
m_dragLane = -1;
m_dragMoved = false;
m_rubberRect = gd::RectF();
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
return true;
}
void PlotView::InvalidatePolylineCache()
{
m_polylineCache.clear();
}
bool PlotView::FitTimeToLane(int laneIndex)
{
if (m_dataSet == nullptr || m_tab == nullptr)
@@ -917,9 +947,6 @@ namespace jdi
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
std::vector<gd::PointF> points;
std::vector<std::size_t> breaks;
SignalRef best;
float bestDistance = tolerance;
@@ -932,17 +959,47 @@ namespace jdi
continue;
}
BuildPolyline(resolved, lane, points, breaks);
// 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同,
// 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
if (cached == nullptr)
{
continue;
}
const std::vector<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& breaks = cached->breaks;
if (points.size() < 2)
{
continue;
}
// 横向先行裁剪:点到线段的距离必然不小于横向距离本身,
// 因此只有横向落在容差范围内的顶点才可能命中。
// 时间单调时折线顶点按 x 递增,可二分定位后只扫描这一小段;
// 否则每次鼠标移动都要遍历上万个顶点(悬停卡顿的来源之一)。
std::size_t windowFirst = 0;
std::size_t windowLast = points.size();
if (resolved.file->timeMonotonic)
{
const auto byX = [](const gd::PointF& point, float limit) { return point.X < limit; };
windowFirst = static_cast<std::size_t>(
std::lower_bound(points.begin(), points.end(), fx - tolerance, byX) - points.begin());
windowLast = static_cast<std::size_t>(
std::upper_bound(points.begin(), points.end(), fx + tolerance,
[](float limit, const gd::PointF& point) { return limit < point.X; }) -
points.begin());
}
if (windowFirst >= windowLast)
{
continue;
}
// 散点模式下把采样点本身也视为可命中目标。
if (resolved.style->mode == DrawMode::SCATTER)
{
for (const gd::PointF& point : points)
for (std::size_t index = windowFirst; index < windowLast; ++index)
{
const gd::PointF& point = points[index];
const float dx = point.X - fx;
const float dy = point.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
@@ -959,7 +1016,9 @@ namespace jdi
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
for (std::size_t index = start + 1; index < stop; ++index)
const std::size_t scanBegin = std::max(start + 1, windowFirst);
const std::size_t scanEnd = std::min(stop, windowLast);
for (std::size_t index = scanBegin; index < scanEnd; ++index)
{
const gd::PointF& a = points[index - 1];
const gd::PointF& b = points[index];
@@ -1770,6 +1829,59 @@ namespace jdi
}
const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref,
const ResolvedSignal& resolved,
const LaneVisual& lane) const
{
if (resolved.data == nullptr || resolved.file == nullptr)
{
return nullptr;
}
// 键 = 构建折线时实际读取的全部输入:
// 信号(引用 + 文件内容 + 采样点数)、时间轴显示范围、绘图区横向几何、网格纵向几何。
// 这些值全部相同时,BuildPolyline 的输出必然完全一致,可直接复用。
const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
for (const PolylineCacheEntry& entry : m_polylineCache)
{
if (entry.ref == ref && entry.file == resolved.file && entry.sampleCount == sampleCount &&
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
entry.laneYMax == lane.yMax)
{
return &entry;
}
}
// 未命中:重建并写入缓存(deque 保证已有元素的引用在追加后依然有效)。
// 缓存只用于消除重复计算,容量上限约等于“网格数 × 每网格信号数”的两倍。
if (m_polylineCache.size() >= kPolylineCacheLimit)
{
m_polylineCache.clear();
}
m_polylineCache.push_back(PolylineCacheEntry());
PolylineCacheEntry& entry = m_polylineCache.back();
entry.ref = ref;
entry.file = resolved.file;
entry.sampleCount = sampleCount;
entry.viewMin = viewMin;
entry.viewMax = viewMax;
entry.plotX = m_plotRect.X;
entry.plotWidth = m_plotRect.Width;
entry.laneY = lane.rect.Y;
entry.laneHeight = lane.rect.Height;
entry.laneYMin = lane.yMin;
entry.laneYMax = lane.yMax;
BuildPolyline(resolved, lane, entry.points, entry.breaks);
// BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。
return &m_polylineCache.back();
}
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks) const
{
@@ -1816,16 +1928,136 @@ namespace jdi
return;
}
std::size_t stride = 1;
const std::size_t visibleCount = end - begin;
if (visibleCount > kMaxPolylinePoints)
{
stride = (visibleCount + kMaxPolylinePoints - 1) / kMaxPolylinePoints;
}
const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f;
const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f;
const std::size_t visibleCount = end - begin;
// 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定
// GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制,
// 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。
std::size_t budget = static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel;
budget = std::max(budget, kMinPolylinePoints);
budget = std::min(budget, kMaxPolylinePoints);
// 采样点密度远超像素分辨率时改用“像素列上下包络”抽稀:
// 每个像素列只保留最小/最大两个顶点(按出现先后排序),
// 既不会漏掉尖峰(等间隔抽样会整段跳过),又把顶点数压到像素量级。
if (visibleCount > budget && resolved.file->timeMonotonic && m_plotRect.Width > 1.0f)
{
const double viewSpan = viewMax - viewMin;
const float plotLeft = m_plotRect.X;
const float plotWidth = m_plotRect.Width;
const std::size_t columns = std::max<std::size_t>(1, budget / 2);
const float columnWidth = plotWidth / static_cast<float>(columns);
const std::vector<double>::const_iterator rangeBegin = time.begin() + static_cast<std::ptrdiff_t>(begin);
const std::vector<double>::const_iterator rangeEnd = time.begin() + static_cast<std::ptrdiff_t>(end);
bool inSegment = false;
for (std::size_t column = 0; column < columns; ++column)
{
const double t0 = viewMin + viewSpan * (static_cast<double>(column) / static_cast<double>(columns));
const double t1 =
viewMin + viewSpan * (static_cast<double>(column + 1) / static_cast<double>(columns));
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(rangeBegin, rangeEnd, t0) - time.begin());
const std::size_t i1 = (column + 1 == columns)
? end
: static_cast<std::size_t>(
std::lower_bound(rangeBegin, rangeEnd, t1) - time.begin());
if (i1 <= i0)
{
// 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。
inSegment = false;
continue;
}
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
std::size_t lowIndex = 0;
std::size_t highIndex = 0;
bool broken = false;
for (std::size_t index = i0; index < i1; ++index)
{
const double value = values[index];
if (!std::isfinite(value))
{
// 该列含无效值:包络绘制到本列结束,后续另起一段。
broken = true;
continue;
}
if (value < low)
{
low = value;
lowIndex = index;
}
if (value > high)
{
high = value;
highIndex = index;
}
}
if (!(low <= high))
{
// 本列全是无效值。
inSegment = false;
continue;
}
const float x = plotLeft + (static_cast<float>(column) + 0.5f) * columnWidth;
const float yTop =
static_cast<float>(ClampDouble(ValueToY(high, lane), limitTop, limitBottom));
const float yBottom =
static_cast<float>(ClampDouble(ValueToY(low, lane), limitTop, limitBottom));
if (yBottom - yTop < kEnvelopeMinPixelSpan)
{
// 波动不足半个像素:只留一个顶点,避免无意义的重复顶点。
if (!inSegment)
{
breaks.push_back(points.size());
inSegment = true;
}
points.push_back(gd::PointF(x, yTop));
}
else
{
if (!inSegment)
{
breaks.push_back(points.size());
inSegment = true;
}
// 按采样点出现的先后顺序连接上下包络,保持走线方向一致。
if (lowIndex <= highIndex)
{
points.push_back(gd::PointF(x, yBottom));
points.push_back(gd::PointF(x, yTop));
}
else
{
points.push_back(gd::PointF(x, yTop));
points.push_back(gd::PointF(x, yBottom));
}
}
if (broken)
{
inSegment = false;
}
}
return;
}
std::size_t stride = 1;
if (visibleCount > budget)
{
stride = (visibleCount + budget - 1) / budget;
}
bool inSegment = false;
for (std::size_t index = begin; index < end; index += stride)
@@ -2080,9 +2312,6 @@ namespace jdi
const int waveSave = g.Save();
g.SetClip(lane.rect, gd::CombineModeIntersect);
std::vector<gd::PointF> points;
std::vector<std::size_t> breaks;
for (const SignalRef& ref : laneSignals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -2092,7 +2321,14 @@ namespace jdi
}
const DrawMode mode = resolved.style->mode;
BuildPolyline(resolved, lane, points, breaks);
// 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
if (cached == nullptr)
{
continue;
}
const std::vector<gd::PointF>& points = cached->points;
const std::vector<std::size_t>& breaks = cached->breaks;
const bool drawLine = (mode != DrawMode::SCATTER);
const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER);