Files
DeepSeek V4.1 Flash 0c320cf1b3 fix(plotview): 修复面板条目栏悬停时预览曲线高亮丢失 + 极端工况绘制管线提速
一、悬停高亮回归修复
- 根因:高亮画在预览曲线绘制路径里,而该路径在命中静态场景缓存时完全不会被调用
  (悬停态又不参与 SceneContentHash),因此缓存命中帧永远看不到高亮。
- 修法:拆出 PaintDerivedCurvesFor(hoverOnly) 两态,新增 PaintDerivedHoverHighlight,
  在 paintOverlays() 中紧跟 PaintHoverHighlight 以前景方式绘制(面板之下、光标之上)。
  高亮仍按面板种类(均值滤波/中心轴/弱峰调整)分别判定。

二、预览曲线绘制重写(单条 15ms → 1.7ms)
- 稠密区间改用与波形基底同一套「逐像素列 min/max 包络带」+ 一次 FillPath;
  旧抽稀折线在稠密视界下每段都是竖直满幅,GDI+ 描边代价正比于覆盖像素数
  (3200 顶点满幅锯齿抗锯齿 128ms / 无抗锯齿 31ms,包络带 1.9ms)。
- 列密度自适应:中等密度 2px/列,极密(>8 采样/像素)4px/列。
- 稀疏区间保持逐点折线;附带修复「等间隔抽样漏掉尖峰」。

三、预览曲线像素缓存层(Picture cache + pan roll)
- 45 条曲线画进带 alpha 的 32bpp DIB,正常帧只做一次 AlphaBlend(1~2ms)。
- 纯平移时整行 memmove 一个 dx 并只重画新露出的竖带(与宽缓冲区滚动同构),
  因此拖动视界的每帧代价与信号数无关。
- 内容指纹 = SceneContentHash(含样式/派生参数/网格几何) ⊕ 曲线发布序号;高亮不进指纹。

四、其他热点
- 光标取值胶囊不再逐帧重算并发布 45 条曲线(CurveSpanFor 新增 computeIfMissing),
  只取已覆盖的缓存曲线,未命中退到最新曲线或本帧不标注。
- 样式补丁路径不再同步重建网格图层(宽基底 2 倍宽 × 2 倍高,实测单次 2.2 秒),
  且开启派生功能时直接走整幅重绘(补丁会覆盖缓存中已烘入的曲线像素)。

实测(15 信号合并一条网格 + 全部实验性功能,1600x900 离屏探针):
拖动视界 264→5.9ms,双光标拖动 187→20.9ms,弱峰滑块 555→89ms,
改信号颜色 2661→200ms,悬停面板行 225→38ms,深放大拖动 207→23.8ms。
全屏 1920x1080:拖动视界 9.3ms。

验证:新增离屏探针 p27 8/8(悬停高亮在缓存命中帧可见、平移/缩放/改颜色后的叠加层
与「完全重画」参考帧逐像素一致 diff=0);p20 9/9、p21 9/9、p22 14/14、p23 8/8、p25 12/12;
make release / make debug 零 warning;Release 814,080 B。规则已同步到 Documents/agent_mem.md。
2026-11-02 18:47:21 +08:00

2284 lines
86 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlotView 实现分片 —— 光栅化像素缓存与每帧预算("怎么画才不卡")。
//
// 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。
// 职责:
// * 信号光晕的离屏位图层(整条曲线一次性渲染 + 每帧贴图);
// * 静态场景缓存(底色 + 网格 + 时间轴)的建立 / 抓取 / 贴回 / 作废;
// * 装饰层(带真 alpha 的独立图层,AlphaBlend 贴回);
// * 每网格"宽基底"图层:分配 / 内容指纹 / 就绪判定 / 并行重建 / 区域重画 / 滚动补条 / 贴图 / 预热预算。
//
// ⚠️ 与绘制(`PlotViewPaint.cpp`)、指纹(`PlotViewCacheKey.cpp`)的分工:
// * 本文件**只管像素与预算**,不做业务判据;
// * 内容指纹由 `PlotViewCacheKey.cpp` 提供(`LaneLayerKey/LaneLayerHash` 里用到的哈希函数在那里);
// * 共用的常量与工具函数在 `PlotViewInternal.hpp`(命名空间 `pvdetail`,全部 `inline`),因此跨分片安全。
#include "PlotViewInternal.hpp"
namespace jdi
{
void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached)
{
if (resolved.style == nullptr)
{
return;
}
// ------------------------------------------------------------------
// 稠密信号(走"像素列包络带"):光晕 = **沿包络带向外扩一圈的填充**。
//
// ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是
// 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数,
// 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms。
// ⚠️⚠️ **必须向外扩 `grow`(= 描边笔宽的一半)**:折线描边的笔宽是
// `lineWidth + 4`,即沿曲线向两侧各扩 2~3px —— 那正是用户看到的"外延高亮描边"。
// 曾经直接按包络带原始几何填充,结果光晕与曲线**逐像素同形**:当信号颜色恰好等于
// 主题强调色时(调色板第 1 色就是强调色),曲线上的那层半透明强调色完全看不出变化,
// 只剩下带宽内部的一点点底色变化 ⇒ 用户报告"高亮几乎看不到、只有曲线换了颜色"
//(2026-10-18 第三轮)。扩一圈后与旧版描边的观感一致。
// ⚠️ 光晕**必须覆盖整条曲线**:只画鼠标附近一段时,在满幅方波上就是一个长方形色块。
// 代价问题由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。
// ------------------------------------------------------------------
if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty())
{
// 与旧版 `PaintSignalHalo` 的笔宽同源:`(lineWidth + 4) * scale`,向外取一半。
const float grow = (std::max(1.0f, resolved.style->lineWidth) + 4.0f) * m_scale * 0.5f;
const int savedSmoothing = g.GetSmoothingMode();
g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益
gd::GraphicsPath bandPath;
bandPath.SetFillMode(gd::FillModeWinding);
gd::PointF quad[4];
for (std::size_t k = 0; k < cached.bandBreaks.size(); ++k)
{
const std::size_t start = cached.bandBreaks[k];
const std::size_t stop =
(k + 1 < cached.bandBreaks.size()) ? cached.bandBreaks[k + 1] : cached.band.size();
const std::size_t total = stop - start;
if (total < 4 || (total % 2) != 0)
{
continue;
}
// 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」,因此
// 列 `i` 的上/下包络分别是 `start+i` 与 `start+half+(half-1-i)`。
//
// ⚠️⚠️ **逐列成对填四边形,而不是把整条包络拼成一个多边形**:满幅方波的
// 包络相邻列区间**互相交叉**,拼成单个多边形后缠绕填充规则会在交叠处
// 互相抵消 ⇒ 填充结果变成一条条竖缝(实测光晕只覆盖约一半带宽,用户看到
// "只有曲线换了个颜色")。逐列四边形永远是简单多边形,不会被挖空。
// ⚠️ 横向/纵向都外扩 `grow`:竖向跃变处旧版 5px 笔会涂出一条整高色带,
// 只填 1px 宽的列区间根本看不出来。相邻四边形同向、且在同一次 `FillPath`
// 内,重叠区域只混合一次(不会叠出更深的竖条)。
const std::size_t half = total / 2;
for (std::size_t column = 0; column + 1 < half; ++column)
{
const gd::PointF& topA = cached.band[start + column];
const gd::PointF& bottomA = cached.band[start + half + (half - 1 - column)];
const gd::PointF& topB = cached.band[start + column + 1];
const gd::PointF& bottomB =
cached.band[start + half + (half - 1 - (column + 1))];
const float highY = std::min(std::min(topA.Y, bottomA.Y), std::min(topB.Y, bottomB.Y));
const float lowY = std::max(std::max(topA.Y, bottomA.Y), std::max(topB.Y, bottomB.Y));
quad[0] = gd::PointF(topA.X - grow, highY - grow);
quad[1] = gd::PointF(topB.X + grow, highY - grow);
quad[2] = gd::PointF(topB.X + grow, lowY + grow);
quad[3] = gd::PointF(topA.X - grow, lowY + grow);
bandPath.AddPolygon(quad, 4);
}
}
if (bandPath.GetPointCount() > 0)
{
// alpha 与旧版描边笔一致(0x66),保证任何信号颜色下观感相同。
gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66)));
g.FillPath(&brush, &bandPath);
}
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(savedSmoothing));
return;
}
// 其余情形一律用折线描边(与"之前"的画法一致)。
PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks);
}
void PlotView::PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, const PolylineCacheEntry& cached)
{
if (resolved.style == nullptr)
{
return;
}
// 光晕只可能画在该网格内:位图 = 网格矩形 ∩ 绘图区(外加少量余量容纳加粗线宽)。
const float pad = (resolved.style->lineWidth + 8.0f) * m_scale;
const float left = std::max(lane.rect.X, m_plotRect.X) - pad;
const float top = std::max(lane.rect.Y, m_plotRect.Y) - pad;
const float right =
std::min(lane.rect.X + lane.rect.Width, m_plotRect.X + m_plotRect.Width) + pad;
const float bottom =
std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height) + pad;
if (!(right > left + 1.0f) || !(bottom > top + 1.0f))
{
return;
}
const int targetX = static_cast<int>(std::floor(left));
const int targetY = static_cast<int>(std::floor(top));
const int width = static_cast<int>(std::ceil(right)) - targetX;
const int height = static_cast<int>(std::ceil(bottom)) - targetY;
if (width <= 0 || height <= 0)
{
return;
}
HaloLayerCache& cache = m_haloLayer;
// 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。
// ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。
const bool sameSize = cache.bitmap && static_cast<int>(cache.bitmap->GetWidth()) == width &&
static_cast<int>(cache.bitmap->GetHeight()) == height;
const bool sameKey =
cache.valid && sameSize && cache.ref == cached.ref && cache.group == cached.group &&
cache.sampleCount == cached.sampleCount && cache.viewMin == cached.viewMin &&
cache.viewMax == cached.viewMax && cache.plotX == cached.plotX &&
cache.plotWidth == cached.plotWidth && cache.laneHeight == cached.laneHeight &&
cache.laneYMin == cached.laneYMin && cache.laneYMax == cached.laneYMax &&
cache.lineWidth == cached.lineWidth && cache.mode == resolved.style->mode &&
cache.gain == resolved.style->gain && cache.absolute == resolved.style->absolute &&
cache.mirrorVertical == resolved.style->mirrorVertical &&
cache.mirrorHorizontal == resolved.style->mirrorHorizontal &&
cache.offsetY == resolved.style->offsetY &&
cache.offsetTime == resolved.style->offsetTime;
if (!sameKey)
{
if (!sameSize)
{
cache.bitmap = std::make_unique<gd::Bitmap>(width, height, PixelFormat32bppARGB);
}
{
gd::Graphics layerGraphics(cache.bitmap.get());
layerGraphics.Clear(gd::Color(0, 0, 0, 0));
layerGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias);
layerGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
// 位图坐标 = 屏幕坐标 - 目标原点,光晕几何可直接沿用屏幕坐标。
layerGraphics.TranslateTransform(static_cast<gd::REAL>(-targetX),
static_cast<gd::REAL>(-targetY));
if (cached.points.size() > kHaloNoAaMinPoints)
{
// 顶点很多的区间(例如深放大后的方波:每段都跨满网格高):
// 抗锯齿描边的扫描线代价 ≈ 线段数 × 覆盖行数(实测可差 20 倍),
// 而这么密的半透明光晕上平滑作用肉眼不可辨。几何完全不变。
layerGraphics.SetSmoothingMode(gd::SmoothingModeNone);
}
PaintSignalHaloFor(layerGraphics, resolved, lane, cached);
}
cache.ref = cached.ref;
cache.group = cached.group;
cache.sampleCount = cached.sampleCount;
cache.viewMin = cached.viewMin;
cache.viewMax = cached.viewMax;
cache.plotX = cached.plotX;
cache.plotWidth = cached.plotWidth;
cache.laneHeight = cached.laneHeight;
cache.laneYMin = cached.laneYMin;
cache.laneYMax = cached.laneYMax;
cache.lineWidth = cached.lineWidth;
cache.mode = resolved.style->mode;
cache.gain = resolved.style->gain;
cache.absolute = resolved.style->absolute;
cache.mirrorVertical = resolved.style->mirrorVertical;
cache.mirrorHorizontal = resolved.style->mirrorHorizontal;
cache.offsetY = resolved.style->offsetY;
cache.offsetTime = resolved.style->offsetTime;
cache.originX = targetX;
cache.originY = targetY;
cache.valid = true;
if (!UploadHaloLayer(width, height))
{
cache.valid = false;
return;
}
}
// 贴图:`AlphaBlend` + `AC_SRC_ALPHA`(源位图已预乘)。命中缓存时只做这一次位块搬运。
if (cache.dc == nullptr)
{
return;
}
const HDC targetDc = g.GetHDC();
if (targetDc != nullptr)
{
BLENDFUNCTION blend = {};
blend.BlendOp = AC_SRC_OVER;
blend.SourceConstantAlpha = 0xFF;
blend.AlphaFormat = AC_SRC_ALPHA;
::AlphaBlend(targetDc, targetX, targetY, width, height, cache.dc, 0, 0, width, height,
blend);
g.ReleaseHDC(targetDc);
}
}
bool PlotView::UploadHaloLayer(int width, int height)
{
HaloLayerCache& cache = m_haloLayer;
if (!cache.bitmap)
{
return false;
}
if (cache.dib == nullptr || cache.dibWidth != width || cache.dibHeight != height)
{
ReleaseHaloLayer();
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下(与 GDI+ 的扫描行顺序一致)
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
HDC memoryDc = CreateCompatibleDC(nullptr);
if (memoryDc == nullptr)
{
return false;
}
void* bits = nullptr;
HBITMAP dib = CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
if (dib == nullptr || bits == nullptr)
{
DeleteDC(memoryDc);
if (dib != nullptr)
{
DeleteObject(dib);
}
return false;
}
SelectObject(memoryDc, dib);
cache.dc = memoryDc;
cache.dib = dib;
cache.bits = bits;
cache.dibWidth = width;
cache.dibHeight = height;
}
// 预乘 alpha:`AlphaBlend` 的 `AC_SRC_ALPHA` 要求源 RGB 已乘过 alpha。
// ⚠️ 用 `(v * a + 128) * 257 >> 16` 代替 `(v * a + 127) / 255`(等价、无除法),
// **不要为此建 64KB 查表**:那张表会让 Release 产物凭空多出 64KB(见 §二进制体积),
// 而这里的乘法-移位在 1.6M 像素上也只要几毫秒。
gd::Rect rect(0, 0, width, height);
gd::BitmapData data = {};
if (cache.bitmap->LockBits(&rect, gd::ImageLockModeRead, PixelFormat32bppARGB, &data) !=
gd::Ok)
{
return false;
}
unsigned char* destination = static_cast<unsigned char*>(cache.bits);
for (int y = 0; y < height; ++y)
{
const unsigned char* source =
static_cast<const unsigned char*>(data.Scan0) + static_cast<std::size_t>(y) * data.Stride;
unsigned char* row = destination + static_cast<std::size_t>(y) * width * 4u;
for (int x = 0; x < width; ++x)
{
const unsigned alpha = source[x * 4 + 3];
if (alpha == 0)
{
row[x * 4 + 0] = 0;
row[x * 4 + 1] = 0;
row[x * 4 + 2] = 0;
row[x * 4 + 3] = 0;
continue;
}
if (alpha == 0xFF)
{
row[x * 4 + 0] = source[x * 4 + 0];
row[x * 4 + 1] = source[x * 4 + 1];
row[x * 4 + 2] = source[x * 4 + 2];
row[x * 4 + 3] = 0xFF;
continue;
}
row[x * 4 + 0] = static_cast<unsigned char>(
(static_cast<unsigned>(source[x * 4 + 0]) * alpha + 128u) * 257u >> 16);
row[x * 4 + 1] = static_cast<unsigned char>(
(static_cast<unsigned>(source[x * 4 + 1]) * alpha + 128u) * 257u >> 16);
row[x * 4 + 2] = static_cast<unsigned char>(
(static_cast<unsigned>(source[x * 4 + 2]) * alpha + 128u) * 257u >> 16);
row[x * 4 + 3] = static_cast<unsigned char>(alpha);
}
}
cache.bitmap->UnlockBits(&data);
return true;
}
void PlotView::ReleaseHaloLayer()
{
HaloLayerCache& cache = m_haloLayer;
if (cache.dc != nullptr)
{
DeleteDC(cache.dc);
cache.dc = nullptr;
}
if (cache.dib != nullptr)
{
DeleteObject(cache.dib);
cache.dib = nullptr;
}
cache.bits = nullptr;
cache.dibWidth = 0;
cache.dibHeight = 0;
cache.valid = false;
}
void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<gd::PointF>& points,
const std::vector<std::size_t>& breaks)
{
// ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点):
// 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。
// 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。
if (resolved.data != nullptr && resolved.data->finiteCount == 1)
{
gd::PointF dot;
if (SinglePointScreenPos(resolved, lane, dot))
{
const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale;
gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale);
g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f);
}
return;
}
const bool drawLine = (resolved.style->mode != DrawMode::SCATTER);
if (!drawLine)
{
// ⚠️ **散点模式的高亮 = 只高亮数据点本身(不画连线)**(用户 2026-10-17 要求):
// 旧实现直接 `return`,于是散点信号悬停时**只有拖拽提示、没有任何高亮**。
// 这里给每个参与绘制的散点套一圈强调色圆环 —— 与「线段/点线」的折线光晕相比,
// 正好是“去掉数据点之间连线的部分”(用户原话)。
// ⚠️ 步长必须与 `PaintLane` 的散点绘制**同源**(`kMaxScatterPoints`),
// 否则高亮圈会出现在没画出来的点上(或漏掉可见的点)。
if (points.empty() || resolved.style == nullptr)
{
return;
}
const float radius = static_cast<float>(
ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
static_cast<double>(kMinScatterRadius), static_cast<double>(kMaxScatterRadius)) *
m_scale);
const float ringRadius = radius + 2.5f * m_scale;
const std::size_t scatterStride =
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
// 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价。
gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 1.6f * m_scale);
for (std::size_t index = 0; index < points.size(); index += scatterStride)
{
const gd::PointF& point = points[index];
g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f,
ringRadius * 2.0f);
}
return;
}
if (breaks.empty())
{
return;
}
gd::Pen halo(ToGdiColor(WithAlpha(m_theme->accent, 0x66)),
(resolved.style->lineWidth + 4.0f) * m_scale);
halo.SetLineJoin(gd::LineJoinRound);
halo.SetStartCap(gd::LineCapRound);
halo.SetEndCap(gd::LineCapRound);
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
if (stop <= start + 1)
{
continue;
}
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
}
}
bool PlotView::EnsureSceneCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
// 位图尺寸必须与当前缓存区域**严格一致**:贴图时按位图尺寸整块拷贝,
// 若沿用更大的旧位图,缩小窗口后会把旧像素画到绘图区之外。
if (m_sceneDc != nullptr && m_sceneWidth == width && m_sceneHeight == height)
{
return true;
}
ReleaseSceneCache();
// 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。
m_panelValid = false;
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
m_sceneDc = CreateCompatibleDC(screen);
m_sceneBitmap = CreateCompatibleBitmap(screen, width, height);
ReleaseDC(nullptr, screen);
if (m_sceneDc == nullptr || m_sceneBitmap == nullptr)
{
ReleaseSceneCache();
return false;
}
m_sceneOldBitmap = SelectObject(m_sceneDc, m_sceneBitmap);
m_sceneWidth = width;
m_sceneHeight = height;
return true;
}
bool PlotView::CaptureSceneCache(gd::Graphics& g)
{
// 缓存区域严格限定为绘图区本身(m_area):工具栏/标签栏/面板由宿主每帧绘制,
// 因为它们各自带有悬停态;绘图区的波形才是真正昂贵的部分(大文件约 80ms/帧)。
const int x = static_cast<int>(std::floor(m_area.X));
const int y = static_cast<int>(std::floor(m_area.Y));
const int right = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int bottom = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
const int width = right - x;
const int height = bottom - y;
if (width <= 0 || height <= 0)
{
m_sceneValid = false;
return false;
}
m_sceneX = x;
m_sceneY = y;
if (!EnsureSceneCache(width, height))
{
m_sceneValid = false;
return false;
}
HDC source = g.GetHDC();
if (source == nullptr)
{
m_sceneValid = false;
return false;
}
const BOOL ok = BitBlt(m_sceneDc, 0, 0, width, height, source, m_sceneX, m_sceneY, SRCCOPY);
g.ReleaseHDC(source);
if (ok == FALSE)
{
m_sceneValid = false;
return false;
}
m_sceneKey = MakeSceneCacheKey();
m_sceneGeometryHash = SceneGeometryHash();
CollectSceneStyles(m_sceneStyles);
CollectSceneDerived(m_sceneDerived);
m_sceneDegraded = false;
m_sceneValid = true;
return true;
}
bool PlotView::BlitSceneCache(gd::Graphics& g)
{
if (!m_sceneValid || m_sceneDc == nullptr)
{
return false;
}
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
const BOOL ok = BitBlt(target, m_sceneX, m_sceneY, m_sceneWidth, m_sceneHeight, m_sceneDc, 0, 0,
SRCCOPY);
g.ReleaseHDC(target);
return ok != FALSE;
}
void PlotView::InvalidateSceneCache()
{
m_sceneValid = false;
m_sceneStyles.clear();
m_sceneDerived.clear();
m_sceneDegraded = false;
m_sceneGeometryHash = 0;
// 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"):
// 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。
m_panelValid = false;
}
void PlotView::ReleaseSceneCache()
{
m_sceneValid = false;
if (m_sceneDc != nullptr)
{
if (m_sceneOldBitmap != nullptr)
{
SelectObject(m_sceneDc, m_sceneOldBitmap);
m_sceneOldBitmap = nullptr;
}
DeleteDC(m_sceneDc);
m_sceneDc = nullptr;
}
if (m_sceneBitmap != nullptr)
{
DeleteObject(m_sceneBitmap);
m_sceneBitmap = nullptr;
}
m_sceneWidth = 0;
m_sceneHeight = 0;
}
bool PlotView::EnsureDecorCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (m_decorDc != nullptr && m_decorBits != nullptr && m_decorW == width && m_decorH == height)
{
return true;
}
ReleaseDecorCache();
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
// ⚠️ **必须用 32bpp DIB 段**(而不是 `CreateCompatibleBitmap`):
// 装饰要带真 alpha(圆角之外全透明),才能用 `AlphaBlend` 合成到波形之上;
// 同时 DIB 段的像素可直接写(用于 alpha 修补与清空)。
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
ReleaseDC(nullptr, screen);
if (dib == nullptr || bits == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (dc == nullptr)
{
DeleteObject(dib);
return false;
}
m_decorOldBitmap = SelectObject(dc, dib);
m_decorDc = dc;
m_decorBitmap = dib;
m_decorBits = bits;
m_decorW = width;
m_decorH = height;
return true;
}
void PlotView::ReleaseDecorCache()
{
m_decorValid = false;
m_decorCapturedRects.clear();
if (m_decorDc != nullptr)
{
if (m_decorOldBitmap != nullptr)
{
SelectObject(m_decorDc, m_decorOldBitmap);
m_decorOldBitmap = nullptr;
}
DeleteDC(m_decorDc);
m_decorDc = nullptr;
}
if (m_decorBitmap != nullptr)
{
DeleteObject(m_decorBitmap);
m_decorBitmap = nullptr;
}
m_decorBits = nullptr;
m_decorW = 0;
m_decorH = 0;
}
void* PlotView::ResolveAlphaBlend()
{
// `AlphaBlend` 由 msimg32 导出、并由 gdi32 的 `GdiAlphaBlend` 实现;
// 两个来源都试一遍,取到即缓存(静态局部变量,仅解析一次)。
static void* cached = nullptr;
static bool resolved = false;
if (resolved)
{
return cached;
}
resolved = true;
HMODULE gdi = GetModuleHandleW(L"gdi32.dll");
if (gdi != nullptr)
{
cached = reinterpret_cast<void*>(GetProcAddress(gdi, "GdiAlphaBlend"));
}
if (cached == nullptr)
{
HMODULE msimg = LoadLibraryW(L"msimg32.dll");
if (msimg != nullptr)
{
cached = reinterpret_cast<void*>(GetProcAddress(msimg, "AlphaBlend"));
}
}
return cached;
}
bool PlotView::RenderDecorCache()
{
m_decorValid = false;
if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty())
{
return false;
}
// 位图覆盖「客户区原点 → 绘图区右下角」:绘制坐标与正常绘制完全一致,
// 不需要任何平移(GDI 文本不认 GDI+ 变换,平移会让文字错位)。
const int width = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int height = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
if (!EnsureDecorCache(width, height))
{
return false;
}
// 清空为全透明(圆角之外必须保持透明,贴回时才不会盖住波形)。
std::memset(m_decorBits, 0, static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4u);
m_decorRects.clear();
{
gd::Graphics g(m_decorDc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
const bool savedUse = m_useDecorCache;
m_useDecorCache = false; // 本次是"真正绘制":不允许走"跳过绘制"的分支
m_renderDecorOnly = true; // 只画装饰(跳过网格线与波形)
const std::vector<double> noTicks;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], noTicks);
}
m_renderDecorOnly = false;
m_useDecorCache = savedUse;
}
// ⚠️ **alpha 修补**:GDI 的文本绘制(`DrawTextW`)会把它写入的像素 alpha 清成 0
// (GDI 不维护 alpha),若原样保留,贴上后文字位置会变成"透明洞"(透出波形)。
// 在这里把装饰矩形内"有颜色但 alpha 为 0"的像素还原为不透明即可 —— 装饰本身
// 是画在不透明底板上的,这些像素本来就该不透明。
for (const gd::RectF& rect : m_decorRects)
{
const int x0 = std::max(0, static_cast<int>(std::floor(rect.X)));
const int y0 = std::max(0, static_cast<int>(std::floor(rect.Y)));
const int x1 = std::min(width, static_cast<int>(std::ceil(rect.X + rect.Width)));
const int y1 = std::min(height, static_cast<int>(std::ceil(rect.Y + rect.Height)));
for (int y = y0; y < y1; ++y)
{
unsigned char* row = static_cast<unsigned char*>(m_decorBits) +
(static_cast<std::size_t>(y) * static_cast<std::size_t>(width)) * 4u;
for (int x = x0; x < x1; ++x)
{
unsigned char* p = row + static_cast<std::size_t>(x) * 4u;
if (p[3] == 0 && (p[0] | p[1] | p[2]) != 0)
{
p[3] = 255;
}
}
}
}
m_decorCapturedRects = m_decorRects;
m_decorValid = true;
return true;
}
bool PlotView::BlitDecorCache(gd::Graphics& g)
{
if (!m_decorValid || m_decorDc == nullptr || m_decorCapturedRects.empty())
{
return false;
}
void* proc = ResolveAlphaBlend();
if (proc == nullptr)
{
return false; // 极小概率:系统不提供 AlphaBlend(贴回会带透明洞,故直接放弃)
}
using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION);
AlphaBlendFn blend = reinterpret_cast<AlphaBlendFn>(proc);
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
// 装饰位图原点 = 客户区 (0,0):源坐标与目标坐标**相同**。
BLENDFUNCTION fn = {};
fn.BlendOp = AC_SRC_OVER;
fn.SourceConstantAlpha = 255;
fn.AlphaFormat = AC_SRC_ALPHA; // 源为**预乘** alpha(GDI+ 写入 DIB 段即预乘)
bool any = false;
for (const gd::RectF& rect : m_decorCapturedRects)
{
const int x = static_cast<int>(std::floor(rect.X));
const int y = static_cast<int>(std::floor(rect.Y));
const int w = static_cast<int>(std::ceil(rect.X + rect.Width)) - x;
const int h = static_cast<int>(std::ceil(rect.Y + rect.Height)) - y;
if (w <= 0 || h <= 0)
{
continue;
}
if (blend(target, x, y, w, h, m_decorDc, x, y, w, h, fn) != FALSE)
{
any = true;
}
}
g.ReleaseHDC(target);
return any;
}
// ---------------------------------------------------------------------------
// 「派生预览曲线」像素缓存
// ---------------------------------------------------------------------------
std::uint64_t PlotView::DerivedOverlayKey() const
{
// 内容指纹 = 场景内容(含外观样式 + 派生参数 + 网格幅值范围 + 网格滚动量 + 绘图区几何)。
// ⚠️ 视界**不在这里**:平移时要靠它判断“能否只滚动像素”,所以视界单独比对。
std::uint64_t hash = SceneContentHash(true, true, true, true);
// 后台算好的派生曲线到达时,曲线像素必须更新(`m_curveSerial` 随每次发布自增)。
{
std::lock_guard<std::mutex> lock(m_curveMutex);
hash ^= m_curveSerial * 1099511628211ull;
}
// 高亮状态**不参与**:高亮由前景单独绘制(见 `PaintDerivedHoverHighlight`)。
return hash;
}
bool PlotView::EnsureDerivedCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (m_derivedDc != nullptr && m_derivedBits != nullptr && m_derivedW == width &&
m_derivedH == height)
{
return true;
}
ReleaseDerivedCache();
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
// 与装饰层同理:必须是 32bpp DIB 段(带真 alpha + 像素可直接 memmove 滚动)。
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
ReleaseDC(nullptr, screen);
if (dib == nullptr || bits == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (dc == nullptr)
{
DeleteObject(dib);
return false;
}
m_derivedOldBitmap = SelectObject(dc, dib);
m_derivedDc = dc;
m_derivedBitmap = dib;
m_derivedBits = bits;
m_derivedW = width;
m_derivedH = height;
m_derivedValid = false;
return true;
}
void PlotView::ReleaseDerivedCache()
{
m_derivedValid = false;
if (m_derivedDc != nullptr)
{
if (m_derivedOldBitmap != nullptr)
{
SelectObject(m_derivedDc, m_derivedOldBitmap);
m_derivedOldBitmap = nullptr;
}
DeleteDC(m_derivedDc);
m_derivedDc = nullptr;
}
if (m_derivedBitmap != nullptr)
{
DeleteObject(m_derivedBitmap);
m_derivedBitmap = nullptr;
}
m_derivedBits = nullptr;
m_derivedW = 0;
m_derivedH = 0;
}
bool PlotView::RenderDerivedCache()
{
m_derivedValid = false;
if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty())
{
return false;
}
const int width = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int height = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
if (!EnsureDerivedCache(width, height))
{
return false;
}
const std::size_t bytes = static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4u;
if (!m_derivedStripActive)
{
// 整块重画:先把全部像素清成透明。
std::memset(m_derivedBits, 0, bytes);
}
else
{
// 补条带:只把这条竖带清成透明(其余像素是滚动过来的有效内容)。
const int x0 = std::max(0, static_cast<int>(std::floor(m_derivedStripX0)));
const int x1 = std::min(width, static_cast<int>(std::ceil(m_derivedStripX1)));
for (int y = 0; y < height; ++y)
{
unsigned char* row = static_cast<unsigned char*>(m_derivedBits) +
(static_cast<std::size_t>(y) * static_cast<std::size_t>(width)) * 4u;
if (x1 > x0)
{
std::memset(row + static_cast<std::size_t>(x0) * 4u, 0,
static_cast<std::size_t>(x1 - x0) * 4u);
}
}
}
{
gd::Graphics g(m_derivedDc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
if (m_derivedStripActive)
{
// ⚠️ 绘制期的横向裁剪(`PaintDerivedCurvesFor` 只做 `intersect`,不会覆盖这一层)。
g.SetClip(gd::RectF(m_derivedStripX0, 0.0f, std::max(0.0f, m_derivedStripX1 - m_derivedStripX0),
static_cast<float>(height)),
gd::CombineModeIntersect);
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintDerivedCurvesFor(g, static_cast<int>(index), m_lanes[index], false);
}
}
m_derivedContentKey = DerivedOverlayKey();
m_derivedViewMin = Tab().ViewMin();
m_derivedViewMax = Tab().ViewMax();
m_derivedValid = true;
return true;
}
bool PlotView::BlitDerivedCache(gd::Graphics& g)
{
if (!m_derivedValid || m_derivedDc == nullptr || m_plotRect.Width < 1.0f)
{
return false;
}
void* proc = ResolveAlphaBlend();
if (proc == nullptr)
{
return false;
}
using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION);
AlphaBlendFn blend = reinterpret_cast<AlphaBlendFn>(proc);
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
// 缓存位图原点 = 客户区 (0,0):源与目标坐标相同。只贴绘图区(曲线被夹在网格内)。
const int x = std::max(0, static_cast<int>(std::floor(m_plotRect.X)));
const int y = std::max(0, static_cast<int>(std::floor(m_plotRect.Y)));
const int w = std::min(m_derivedW - x, static_cast<int>(std::ceil(m_plotRect.Width)));
const int h = std::min(m_derivedH - y, static_cast<int>(std::ceil(m_plotRect.Height)));
if (w <= 0 || h <= 0)
{
g.ReleaseHDC(target);
return false;
}
BLENDFUNCTION fn = {};
fn.BlendOp = AC_SRC_OVER;
fn.SourceConstantAlpha = 255;
fn.AlphaFormat = AC_SRC_ALPHA;
const BOOL done = blend(target, x, y, w, h, m_derivedDc, x, y, w, h, fn);
g.ReleaseHDC(target);
return done != FALSE;
}
void PlotView::PaintDerivedOverlayLayer(gd::Graphics& g)
{
if (m_loadingBusy || m_lanes.empty())
{
return; // 加载遮罩期间不花时间在派生曲线上(本来也看不见)
}
// 没有任何派生功能开启时直接跳过(并释放缓存,避免白占内存)。
bool anyFeature = false;
for (const Lane& lane : Tab().Lanes())
{
if (lane.edgeCenter || lane.meanFilter || lane.weakPeak)
{
anyFeature = true;
break;
}
}
if (!anyFeature)
{
m_derivedValid = false;
return;
}
const std::uint64_t contentKey = DerivedOverlayKey();
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
bool needRender = true;
bool rolled = false;
m_derivedStripActive = false;
if (m_derivedValid && m_derivedContentKey == contentKey && span > 0.0)
{
const double cachedSpan = m_derivedViewMax - m_derivedViewMin;
const double sameSpanTolerance = std::max(1.0e-12, std::fabs(span) * 1.0e-9);
if (std::fabs(cachedSpan - span) <= sameSpanTolerance &&
m_derivedViewMin == viewMin && m_derivedViewMax == viewMax)
{
needRender = false; // 完全同源:只贴图
}
else if (std::fabs(cachedSpan - span) <= sameSpanTolerance && m_plotRect.Width > 1.0f)
{
// 纯平移:像素整体横移,只重画新露出的竖带(与宽缓冲区的滚动同构)。
const double pxPerTime = static_cast<double>(m_plotRect.Width) / span;
const int shift = static_cast<int>(
std::lround((m_derivedViewMin - viewMin) * pxPerTime));
const int width = m_derivedW;
if (shift != 0 && std::abs(shift) < width)
{
const std::size_t rowBytes = static_cast<std::size_t>(width) * 4u;
const std::size_t moveBytes =
rowBytes - static_cast<std::size_t>(std::abs(shift)) * 4u;
unsigned char* bits = static_cast<unsigned char*>(m_derivedBits);
if (bits != nullptr && moveBytes > 0)
{
for (int y = 0; y < m_derivedH; ++y)
{
unsigned char* row = bits + static_cast<std::size_t>(y) * rowBytes;
if (shift > 0)
{
// 内容右移:从本行右端往前搬(`memmove` 自带重叠安全)。
std::memmove(row + static_cast<std::size_t>(shift) * 4u, row, moveBytes);
}
else
{
// 内容左移:源在本行右侧 —— 目标**不能**越过行首(否则越界)。
std::memmove(row, row + static_cast<std::size_t>(-shift) * 4u, moveBytes);
}
}
}
const float plotLeft = m_plotRect.X;
const float plotRight = m_plotRect.X + m_plotRect.Width;
if (shift > 0)
{
m_derivedStripX0 = plotLeft;
m_derivedStripX1 = std::min(plotRight, plotLeft + static_cast<float>(shift) + 1.0f);
}
else
{
m_derivedStripX0 = std::max(plotLeft, plotRight + static_cast<float>(shift) - 1.0f);
m_derivedStripX1 = plotRight;
}
if (m_derivedStripX1 > m_derivedStripX0 + 0.5f)
{
m_derivedStripActive = true;
rolled = true;
}
}
needRender = true;
}
}
if (needRender)
{
if (!RenderDerivedCache())
{
m_derivedStripActive = false;
return;
}
}
m_derivedStripActive = false;
if (!BlitDerivedCache(g))
{
// 无法 AlphaBlend:退回逐条曲线直接绘制(正确性优先,代价高但极少发生)。
if (!rolled)
{
m_derivedValid = false;
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLaneDerivedOverlays(g, static_cast<int>(index), m_lanes[index]);
}
}
}
bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (layer.dc != nullptr && layer.width == width && layer.height == height)
{
return true;
}
ReleaseLaneLayer(layer);
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
layer.dc = CreateCompatibleDC(screen);
layer.bitmap = CreateCompatibleBitmap(screen, width, height);
ReleaseDC(nullptr, screen);
if (layer.dc == nullptr || layer.bitmap == nullptr)
{
ReleaseLaneLayer(layer);
return false;
}
layer.oldBitmap = SelectObject(layer.dc, layer.bitmap);
layer.width = width;
layer.height = height;
layer.valid = false;
return true;
}
void PlotView::ReleaseLaneLayer(LaneLayer& layer)
{
layer.valid = false;
if (layer.dc != nullptr)
{
if (layer.oldBitmap != nullptr)
{
SelectObject(layer.dc, layer.oldBitmap);
layer.oldBitmap = nullptr;
}
DeleteDC(layer.dc);
layer.dc = nullptr;
}
if (layer.bitmap != nullptr)
{
DeleteObject(layer.bitmap);
layer.bitmap = nullptr;
}
layer.width = 0;
layer.height = 0;
}
void PlotView::ReleaseWideBuffer()
{
std::lock_guard<std::mutex> lock(m_layerMutex);
for (LaneLayer& layer : m_layerFront)
{
ReleaseLaneLayer(layer);
}
m_layerFront.clear();
m_layerRebuildPending.clear();
}
void PlotView::InvalidateWideBuffer()
{
std::lock_guard<std::mutex> lock(m_layerMutex);
for (LaneLayer& layer : m_layerFront)
{
layer.valid = false;
}
m_layerRebuildPending.clear();
}
std::uint64_t PlotView::LaneLayerKey(int laneIndex) const
{
// 图层只含基底(不含派生曲线与装饰),因此指纹 = **该网格自己的内容**
// ⊕ 网格下标 ⊕ 视界跨度 ⊕ 绘图区宽度(决定像素/时间与位图尺寸)。
const double span = Tab().ViewMax() - Tab().ViewMin();
std::uint64_t key = LaneLayerHash(laneIndex);
key ^= static_cast<std::uint64_t>(laneIndex + 1) * 1315423911ull;
key *= 1099511628211ull;
key ^= static_cast<std::uint64_t>(std::llround(span * 1.0e6));
key *= 1099511628211ull;
key ^= static_cast<std::uint64_t>(std::llround(m_plotRect.Width));
return key;
}
std::uint64_t PlotView::LaneLayerHash(int laneIndex) const
{
// 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的
// 可见性与外观。
// ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在**
// 本指纹里;改信号名不需要重建图层。
//
// ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin`
// 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素),
// 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash ^= value;
hash *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value)
{
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return hash;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
mixFloat(visual.rect.Height);
mixFloat(static_cast<float>(visual.yMax - visual.yMin));
const std::vector<SignalRef>& signals = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].signals;
mix(signals.size());
for (const SignalRef& ref : signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex));
mix(resolved.visible ? 1ull : 0ull);
if (resolved.data == nullptr || resolved.style == nullptr)
{
continue;
}
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 「调整」变换会改变图层里的波形,也必须让图层失效
//(否则贴回的旧像素会与当前样式不一致)。
mixFloat(resolved.style->gain);
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
return hash;
}
int PlotView::LayerMarginX() const
{
const int width = static_cast<int>(std::lround(m_plotRect.Width));
return std::max(1, width / 2); // 左右各半屏
}
int PlotView::LayerMarginY(float laneHeight) const
{
// 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围,
// 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。
const int half = static_cast<int>(std::lround(static_cast<double>(laneHeight) * 0.5));
return std::max(1, half);
}
double PlotView::LayerDefaultWideMin() const
{
// 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。
const double span = Tab().ViewMax() - Tab().ViewMin();
const int width = static_cast<int>(std::lround(m_plotRect.Width));
if (!(span > 0.0) || width <= 0)
{
return Tab().ViewMin();
}
const double scale = static_cast<double>(width) / span;
return Tab().ViewMin() - static_cast<double>(LayerMarginX()) / scale;
}
bool PlotView::LayersStructurallyReady() const
{
std::lock_guard<std::mutex> lock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty())
{
return false;
}
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int bitmapWidth = width + 2 * LayerMarginX();
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
const LaneLayer& layer = m_layerFront[index];
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth)
{
return false;
}
// 跨度必须一致:跨度变了像素/时间的映射就不同,旧像素不能再当“坐标偏移”用。
if (std::fabs(span - layer.span) > 1.0e-12)
{
return false;
}
// 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。
const float laneHeight = m_lanes[index].rect.Height;
const int bitmapHeight =
static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (layer.height != bitmapHeight)
{
return false;
}
}
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()))
{
return false;
}
std::lock_guard<std::mutex> lock(m_layerMutex);
if (laneIndex >= static_cast<int>(m_layerFront.size()))
{
return false;
}
const LaneLayer& layer = m_layerFront[static_cast<std::size_t>(laneIndex)];
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int bitmapWidth = width + 2 * LayerMarginX();
const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height;
const int bitmapHeight = static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth ||
layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12)
{
return false;
}
return layer.key == LaneLayerKey(laneIndex);
}
bool PlotView::LanesAllReadyForView() const
{
if (m_lanes.empty())
{
return false;
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
if (!LaneLayerReady(static_cast<int>(index)))
{
return false;
}
}
return true;
}
bool PlotView::LaneVisuallyRelevant(int laneIndex) const
{
return LaneVisuallyRelevant(laneIndex, 0);
}
bool PlotView::LaneVisuallyRelevant(int laneIndex, int prefetchScreens) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
if (!(m_plotRect.Height > 0.0f))
{
return false;
}
const gd::RectF& rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect;
// 可视判定本身留一个网格高的余量(即将滚入视区的网格也算相关);
// 预取带在其上再按屏高放大(见头文件说明)。
const float margin = std::max(rect.Height, 0.0f) +
static_cast<float>(std::max(0, prefetchScreens)) * m_plotRect.Height;
return (rect.Y + rect.Height + margin > m_plotRect.Y) &&
(rect.Y - margin < m_plotRect.Y + m_plotRect.Height);
}
bool PlotView::LanesVisiblyReadyForView() const
{
if (m_lanes.empty())
{
return false;
}
bool anyRelevant = false;
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
if (!LaneVisuallyRelevant(static_cast<int>(index)))
{
continue;
}
anyRelevant = true;
if (!LaneLayerReady(static_cast<int>(index)))
{
return false;
}
}
return anyRelevant;
}
bool PlotView::AnyVisibleLanePending() const
{
// ⚠️ 用 **±1 屏预取带**(而不是“严格可见”):网格滚动动画会在几百毫秒内扫过一整个屏高,
// 只预建严格可见的网格时,动画扫进去的每一帧都要靠底衬兜底重画十几份网格
// (实测 ~8ms/帧 ⇒ 动画明显卡顿)。预取带让静置期间就把附近网格的图层建好。
for (const int laneIndex : m_layerRebuildPending)
{
if (LaneVisuallyRelevant(laneIndex, 1))
{
return true;
}
}
return false;
}
void PlotView::QueueLaneRebuild(int laneIndex)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return;
}
for (const int existing : m_layerRebuildPending)
{
if (existing == laneIndex)
{
return; // 已经在队列里
}
}
m_layerRebuildPending.push_back(laneIndex);
}
bool PlotView::RenderPendingLaneRebuilds(int budget)
{
if (m_layerRebuildPending.empty() || budget <= 0)
{
return !m_layerRebuildPending.empty();
}
// ⚠️ **优先补画可视网格**:队列是按下标顺序登记的,若严格 FIFO,用户正在看的那几个
// 网格(往往位于列表下半部)可能要等上百帧才轮到,期间画面一直停留在兜底状态
//(用户报告:缩放 / 折起后“需要等一会才正确加载”)。
// 做一次**稳定划分**:可视网格排到队首,其余保持原有相对顺序。
std::stable_partition(m_layerRebuildPending.begin(), m_layerRebuildPending.end(),
[this](int laneIndex) { return LaneVisuallyRelevant(laneIndex); });
const double defaultWideMin = LayerDefaultWideMin();
// 本批要重画的网格:`budget` 足够大时一次取完(视界已停时调用方会传很大的预算)。
// ⚠️ **离屏网格一律丢弃**(不重画、也不留在队列里):它们的像素当前不可见,
// 而单个巨型信号的图层重建要 10~30ms。真正进入可视/预取带时 `RollWideBuffer`
// 会按新视界重新登记它们(见那里的说明)。历史实现会排空整个队列 ⇒ 勾选一个分组后
// 连续数秒每帧 10~30ms(用户 2026-10-18 报告的列表动画卡顿)。
m_layerRebuildPending.erase(
std::remove_if(m_layerRebuildPending.begin(), m_layerRebuildPending.end(),
[this](int laneIndex) { return !LaneVisuallyRelevant(laneIndex, 1); }),
m_layerRebuildPending.end());
if (m_layerRebuildPending.empty())
{
return false;
}
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[static_cast<std::size_t>(index)];
if (laneIndex >= 0 && laneIndex < static_cast<int>(m_lanes.size()))
{
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);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
break;
}
LaneLayer& layer = m_layerFront[static_cast<std::size_t>(laneIndex)];
// ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。
// 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"),
// 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格,
// 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。
// 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。
if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin,
m_lanes[static_cast<std::size_t>(laneIndex)].yMax))
{
continue;
}
}
}
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)
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
layer.valid = false;
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏
const int bitmapWidth = width + 2 * margin;
const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height;
const int lanePixelHeight = static_cast<int>(std::ceil(laneHeight));
const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高
const int bitmapHeight = lanePixelHeight + 2 * marginY;
if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight))
{
return false;
}
const double scale = static_cast<double>(width) / span; // 像素 / 时间
const double laneSpan = m_lanes[static_cast<std::size_t>(laneIndex)].yMax -
m_lanes[static_cast<std::size_t>(laneIndex)].yMin;
const double pxPerValue = (laneSpan > 0.0) ? static_cast<double>(laneHeight) / laneSpan : 0.0;
if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth)
{
stripX = 0;
stripW = bitmapWidth;
}
// 纵向同理:`stripH <= 0` 表示纵向整块(横向滚动的细条就是这种情形)。
if (stripH <= 0 || stripY < 0 || stripY + stripH > bitmapHeight)
{
stripY = 0;
stripH = bitmapHeight;
}
// 保存当前布局 / 视图 / 网格,临时切换到"宽窗口"映射后离屏渲染,再恢复。
const gd::RectF savedArea = m_area;
const gd::RectF savedPlot = m_plotRect;
const double savedViewMin = Tab().ViewMin();
const double savedViewMax = Tab().ViewMax();
const std::vector<LaneVisual> savedLanes = m_lanes;
const float savedLaneContentHeight = m_laneContentHeight;
const bool savedFastFrame = m_fastFrame;
const bool savedRenderWide = m_renderWide;
const float savedScroll = Tab().LaneScroll();
const double savedWideTickSpan = m_wideTickSpan;
const float savedWideTickHeight = m_wideTickHeight;
const double savedRenderTimeStep = m_renderTimeStep;
m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth),
static_cast<float>(bitmapHeight));
// 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面;
// 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;
m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰
// 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。
m_fastFrame = false;
// 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值):
// 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。
m_wideTickSpan = laneSpan;
m_wideTickHeight = laneHeight;
// ⚠️⚠️ **不要在这里再调 `BuildLaneVisuals()`**:它会对**全部网格**重算
// `ComputeLaneRange`(O(网格数)),而本函数只需要**被渲染的那一个网格**。
// 253 个网格、30 个可见网格时,每帧 30 次补条 × 2 次 BuildLaneVisuals
// = 单帧 **26.8ms**(用户 2026-10-17 报告:三明治加载后拖拽视界卡顿)。
// 该网格的矩形/幅值范围已在下方显式设置(`m_lanes` 其余成员保持原值不变)。
// ⚠️ **该网格的幅值映射必须以本次渲染的纵向锚点为准**(而不是"当前 `yMax`"):
// - 整块重画 / 纵向补条:锚点就是当前 `yMax`(纵向补条是滚动后的值),二者一致;
// - **横向补细条**:只重画一条竖带,其余像素仍在**原锚点**上;若这里用当前 `yMax`
// 渲染,竖带与其余像素的纵向映射就不同源,贴图后出现撕裂/错位。
// 详见 `RenderLaneLayerRegion` 的 `anchorYMax` 形参说明。
m_lanes[static_cast<std::size_t>(laneIndex)].yMax = anchorYMax;
m_lanes[static_cast<std::size_t>(laneIndex)].yMin = anchorYMax - laneSpan;
// 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。
// 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算**
//(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`),
// 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射
// 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差,
// 让拖拽期间的像素残差明显变大)。
if (pxPerValue > 0.0)
{
const double topExtend = static_cast<double>(marginY) / pxPerValue;
const double bottomRows =
static_cast<double>(bitmapHeight - marginY) - static_cast<double>(laneHeight);
const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue;
m_lanes[static_cast<std::size_t>(laneIndex)].yMin -= bottomExtend;
m_lanes[static_cast<std::size_t>(laneIndex)].yMax += topExtend;
}
m_lanes[static_cast<std::size_t>(laneIndex)].rect =
gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth), static_cast<float>(bitmapHeight));
{
gd::Graphics g(layer.dc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit);
UiFillRect(g, m_area, m_theme->plotBg);
UiFillRect(g, m_area, ThemeLaneBackground(*m_theme));
// 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。
// ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`):
// 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。
const double fgSpan = savedViewMax - savedViewMin;
const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget);
m_renderTimeStep = timeStep;
std::vector<double> timeTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks);
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
m_renderTimeStep = 0.0;
}
m_fastFrame = savedFastFrame;
m_renderWide = savedRenderWide;
m_wideTickSpan = savedWideTickSpan;
m_wideTickHeight = savedWideTickHeight;
m_renderTimeStep = savedRenderTimeStep;
m_stripRenderActive = savedStripActive;
m_stripX0 = savedStripX0;
m_stripX1 = savedStripX1;
// 恢复布局 / 视图 / 网格。
// ⚠️ 这里**不调 `BuildLaneVisuals()`**(同上面的原因:O(网格数) 的 `ComputeLaneRange`
// 在每次补条时都跑一遍是拖拽卡顿的主因)。`m_lanes` 在进入本函数时已经被完整拷贝,
// 直接拷回即可 —— 它本来就是正常布局下的正确结果(矩形/幅值/内容高度都在里面)。
m_area = savedArea;
m_plotRect = savedPlot;
Tab().ViewMin() = savedViewMin;
Tab().ViewMax() = savedViewMax;
m_lanes = savedLanes;
m_laneContentHeight = savedLaneContentHeight;
Tab().LaneScroll() = savedScroll;
layer.wideMin = wideMin;
layer.span = span;
layer.marginY = marginY;
// 纵向锚点 = 本次渲染的像素所对应的 `yMax`(由调用方按"改动了哪部分像素"决定;
// 横向补细条必须沿用图层原锚点,否则残余的纵向偏移会被静默清零 —— 见函数说明)。
layer.anchorYMax = anchorYMax;
layer.pxPerValue = pxPerValue;
layer.key = LaneLayerKey(laneIndex);
layer.valid = true;
m_layerRenderCount.fetch_add(1, std::memory_order_relaxed);
return true;
}
bool PlotView::RebuildWideBuffer()
{
const float plotWidth = m_plotRect.Width;
const float areaHeight = m_area.Height;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(areaHeight > 8.0f) || !(span > 0.0) || m_lanes.empty())
{
InvalidateWideBuffer();
return false;
}
// 只在**扩容 + 清空待重画队列**这一段持有 `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)
{
if (!m_layerFront[index].valid)
{
ok = false;
}
}
return ok;
}
bool PlotView::PrefetchStepAllowed()
{
const std::uint64_t now = GetTickCount64();
if (now >= m_lastPrefetchRequestMs &&
(now - m_lastPrefetchRequestMs) < kPrefetchMinIntervalMs)
{
return false;
}
m_lastPrefetchRequestMs = now;
return true;
}
void PlotView::EnsureLaneLayersPrefetched()
{
if (m_lanes.empty() || !(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0))
{
return;
}
if (!LayersAllocated())
{
// 图层数组与网格数量不符(首次绘制 / 增删网格):没有可复用的像素,只能整批建立。
RebuildWideBuffer();
return;
}
// ⚠️ 只登记「缺失 / 内容过期」的网格(`RollWideBuffer` 不做整块重画),
// 由 `RenderPendingLaneRebuilds` 的既有预算逐帧补齐。
RollWideBuffer();
}
void PlotView::WarmLaneLayersStep()
{
if (m_dragMode != PlotDragMode::NONE || m_lanes.empty() || m_fonts == nullptr)
{
return;
}
// ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会
// 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。
if (m_animationBusy)
{
return;
}
if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0))
{
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())
{
// 网格变少:先把多出来的图层位图释放掉(`resize` 不会调用 `ReleaseLaneLayer`)。
for (std::size_t index = m_lanes.size(); index < m_layerFront.size(); ++index)
{
ReleaseLaneLayer(m_layerFront[index]);
}
}
if (m_layerFront.size() != m_lanes.size())
{
m_layerFront.resize(m_lanes.size());
}
}
// 登记"跨度 / 内容不一致"的网格。空闲时视界未变 ⇒ `RollWideBuffer` 只登记、不做滚动,
// 也不会走"位移超出画布 → 立刻整块重画"那条分支。
RollWideBuffer();
RenderPendingLaneRebuilds(1);
// ⚠️ 是否继续请求下一帧:
// * 还有**可视 / 预取带内**的网格待补 ⇒ 立刻继续(收敛快,见 `AnyVisibleLanePending`);
// * 只剩**离屏**网格 ⇒ 按 `kPrefetchMinIntervalMs` **限速**继续(慢速后台预热,
// 既不饿死动画定时器,又能把整批图层建好 —— 见 `PrefetchStepAllowed`)。
const bool morePending =
AnyVisibleLanePending() ||
(!m_layerRebuildPending.empty() && PrefetchStepAllowed());
if (morePending)
{
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
}
void PlotView::RollWideBuffer()
{
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int margin = LayerMarginX();
const int bitmapWidth = width + 2 * margin;
const double scale = static_cast<double>(width) / span;
std::lock_guard<std::recursive_mutex> renderLock(m_renderMutex);
std::lock_guard<std::mutex> layerLock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty())
{
return;
}
// 本帧还剩多少个「重新居中」额度(见 `kLayerRollsPerFrame`)。
int rollBudget = kLayerRollsPerFrame;
// ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`),
// 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移,
// 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾,
// 表现为严重的绘制错误与波形抖动。
//
// ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列
//(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。
// 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
LaneLayer& layer = m_layerFront[index];
const int laneIndex = static_cast<int>(index);
// ⚠️⚠️ **不在可视区(含预取带)内的网格:整段跳过 —— 必须放在最前面**。
//
// 它们的像素当前根本不会被贴出来,滚了也看不见;而每次补条都要重跑
// `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。
//
// 而且离屏网格**既不登记待重画、也不算内容指纹**:
// * 登记待重画:一次"网格数变化"会把上百个离屏网格塞进队列,随后每帧补 1 个
// × 10~30ms ⇒ 数秒持续掉帧(用户 2026-10-18 报告);
// * 算 `LaneLayerKey()`:那是**逐网格内容哈希**,283 个网格全算一遍实测约
// 0.7ms/帧,而其中 270 个的结果根本用不到 —— 这就是"空闲帧比强制整帧还慢"
// 的根因(用户 2026-10-29 要求排查的既有性能问题)。实测把它提到前面之后
// 空闲帧从 2.4ms 降到约 1.2ms。
//
// 离屏网格真正进入可视/预取带时,下面的分支会按新视界登记并重建它。
if (!LaneVisuallyRelevant(laneIndex, 1))
{
continue;
}
// 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。
// ⚠️ 只对**可视网格**登记待重画(离屏的由上面那段说明处理)。
const float laneHeight = m_lanes[index].rect.Height;
const int bitmapHeight =
static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth ||
layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12)
{
QueueLaneRebuild(laneIndex);
continue;
}
// 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。
const double desired = (Tab().ViewMin() - layer.wideMin) * scale;
const int shift = static_cast<int>(std::llround(desired - static_cast<double>(margin)));
// 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。
const bool contentStale = (layer.key != LaneLayerKey(laneIndex));
// 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。
int vOffset = 0;
if (layer.pxPerValue > 0.0)
{
vOffset = static_cast<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
// ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 2026-10-18 报告:
// 快速拖动中键时**所有网格的信号一起消失**,因为一旦作废就要等拖拽结束才重建)。
// 关键认识:`BlitWideBuffer` 按 `(ViewMin - layer.wideMin) * scale` 算源偏移,
// 只要视界仍落在位图覆盖范围内(`|shift| <= margin`),**不滚动也画得完全正确**。
// 因此这里分三种情形:
// * `|shift| <= driftLimit`(位图仍完整覆盖视界)⇒ **什么都不做**(免费);
// * 超出 ⇒ 向中心滚,但**每帧最多滚 `kLayerRollMaxStepPixels`**(把补条代价摊开),
// 剩余位移继续由贴图偏移承担 ⇒ **永远不会空白**;
// * 真的跳到画布之外(`|shift| >= bitmapWidth`)⇒ 才作废 + 登记待重画
// (那是“点选信号跳转”这类整屏换地方的情形,本来就要重画)。
const int driftLimit = std::max(8, margin - 24);
const int vOffsetAbs = std::abs(vOffset);
if (vOffsetAbs >= layer.height)
{
// 纵向已超出画布(画布高约等于 2×网格高):像素映射不再可用。
layer.valid = false;
QueueLaneRebuild(laneIndex);
continue;
}
if (contentStale)
{
QueueLaneRebuild(laneIndex);
}
// ------------------------------------------------------------------
// 纵向(幅值)滚动:与横向**完全同构**——内容整体上下移动若干行,
// 再只补画新暴露的那一条(整宽 × 行数)。
//
// ⚠️ **必须先做纵向再做横向**:横向补条由 `RenderLaneLayerRegion` 渲染,
// 而该函数按传入的**纵向锚点**渲染细条;若先补横向,那时锚点还没跟上,
// 补出来的细条会带着一个错的纵向偏移(贴图后表现为波形错位/撕裂)。
// ⚠️ 没有这一步时:幅值一变就超出余量 → 每帧都排队**整块重画**(画布比可见网格高
// 3 倍,代价很大)→ 拖动时"抽搐";而且在重画完成前,`BlitWideBuffer` 只能贴出部分
// 行(其余留空)→ 网格内出现**空白条带**(用户报告的"绘制异常")。
//
// ⚠️ **滚动量必须量化**(`kLayerRollQuantum`):不足一个量子的偏移**不滚**,
// 交给贴图时的纵向偏移(`srcY`)处理。这样图层的画布映射在拖动过程中**保持稳定**,
// 折线缓存(键含幅值映射)得以命中;否则每帧滚 1~30 行会让折线每帧重算
// (实测同样的拖动:量化前 3~4ms 的滚动 + 10~14ms 整帧,量化后回到 ~5ms 量级)。
// 余量足够大(上下各 `marginY`),因此"不滚只偏移"绝不会超出画布。
// 上面已保证 |vOffset| < 画布高,所以这里的 BitBlt 高度恒为正。
// ------------------------------------------------------------------
if (std::abs(vOffset) >= kLayerRollQuantum)
{
const int roll = vOffset; // 正 = 内容整体上移 roll 行(窗口向下移动)
int stripY = 0;
int stripH = 0;
if (roll > 0)
{
// 内容上移:new[y] = old[y + roll],底部暴露 roll 行。
BitBlt(layer.dc, 0, 0, bitmapWidth, layer.height - roll, layer.dc, 0, roll, SRCCOPY);
stripY = layer.height - roll;
stripH = roll;
}
else
{
const int amount = -roll;
// 内容下移:new[y] = old[y - amount],顶部暴露 amount 行。
BitBlt(layer.dc, 0, amount, bitmapWidth, layer.height - amount, layer.dc, 0, 0,
SRCCOPY);
stripY = 0;
stripH = amount;
}
// 锚点随之移动(与横向 `layer.wideMin += shift / scale` 同一套记账方式)。
layer.anchorYMax -= static_cast<double>(roll) / layer.pxPerValue;
// 补画暴露的那一条(整宽)。锚点用**滚动后**的值 —— 该条与已滚动的像素同一映射。
RenderLaneLayerRegion(laneIndex, layer, 0, bitmapWidth, stripY, stripH, layer.wideMin,
layer.anchorYMax);
}
// ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明):
// `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移
// **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要
// 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。
if (std::abs(shift) <= driftLimit)
{
continue;
}
// ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`),且单次滚动量有上限
// (`kLayerRollMaxStepPixels`):不重居中也不会画错,超出部分继续由贴图偏移承担,
// 因此可以安全地分帧完成。
if (rollBudget <= 0)
{
continue;
}
--rollBudget;
const int maxStep = std::min(kLayerRollMaxStepPixels, bitmapWidth / 2);
const int roll = std::max(-maxStep, std::min(maxStep, shift));
// 整数像素自贴图平移:内容整体移动 `roll` 像素。
if (roll > 0)
{
// 内容整体左移 roll:new[x] = old[x + roll],右侧暴露 roll 列。
BitBlt(layer.dc, 0, 0, bitmapWidth - roll, layer.height, layer.dc, roll, 0, SRCCOPY);
}
else if (roll < 0)
{
const int amount = -roll;
BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY);
}
else
{
continue;
}
layer.wideMin += static_cast<double>(roll) / scale;
// 只补画新暴露的细条(每帧代价恒为 O(细条))。
// ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界"
// 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。
// ⚠️ **纵向锚点必须沿用 `layer.anchorYMax`**(不能传当前 `yMax`):横向补条只改一条
// 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素
// 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素
// 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。
const int stripWidth = std::min(std::abs(roll), bitmapWidth - 1);
const int stripX = (roll > 0) ? (bitmapWidth - stripWidth) : 0;
RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, 0, 0, layer.wideMin,
layer.anchorYMax);
}
}
bool PlotView::BlitWideBuffer(gd::Graphics& g, const std::vector<double>& timeTicks)
{
const float plotWidth = m_plotRect.Width;
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(plotWidth > 8.0f) || !(span > 0.0))
{
return false;
}
const int width = static_cast<int>(std::lround(plotWidth));
const int margin = LayerMarginX();
const int bitmapWidth = width + 2 * margin;
const double scale = static_cast<double>(width) / span;
/** 一次图层贴图(所有参数在释放图层锁之前算好,避免持锁期间做 GDI 绘制)。 */
struct LayerBlit
{
HDC dc = nullptr;
int srcX = 0;
int srcY = 0;
int destX = 0;
int destY = 0;
int width = 0;
int height = 0;
};
std::vector<LayerBlit> blits;
std::vector<int> backing; // 本帧拿不到(完整)图层像素的网格
blits.reserve(m_lanes.size());
backing.reserve(m_lanes.size());
{
std::lock_guard<std::mutex> lock(m_layerMutex);
if (m_layerFront.size() != m_lanes.size())
{
return false;
}
for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{
const LaneLayer& layer = m_layerFront[index];
const gd::RectF& lane = m_lanes[index].rect;
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)
{
continue; // 网格整体移出可视区
}
const int bandHeight = static_cast<int>(std::lround(bottom - top));
// 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量",
// 且时间跨度必须与当前视界一致(跨度不同则像素的时间映射已经不对)。
const int expectBitmapHeight =
static_cast<int>(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height);
bool usable = (layer.valid && layer.dc != nullptr && layer.width == bitmapWidth &&
layer.height == expectBitmapHeight && layer.span > 0.0 &&
std::fabs(layer.span - span) <= 1.0e-12);
LayerBlit blit;
if (usable)
{
// ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的
// 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 ——
// 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。
int vOffset = 0;
if (layer.pxPerValue > 0.0)
{
vOffset = static_cast<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
int srcY = static_cast<int>(std::lround(top - lane.Y)) + layer.marginY + vOffset;
int height = bandHeight;
int destTopOffset = 0;
// 超出画布的上下两端只贴**有像素的那一段**(其余由网格底衬补齐)。
if (srcY < 0)
{
destTopOffset = -srcY;
height -= destTopOffset;
srcY = 0;
}
if (srcY + height > layer.height)
{
height = layer.height - srcY;
}
// ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。
// 这里只贴**有像素的那一段**,其余由底衬补齐;整块跳过才是"网格瞬间变空"的直接原因。
const double subX = (Tab().ViewMin() - layer.wideMin) * scale;
int srcX = static_cast<int>(std::lround(subX));
int destOffset = 0; // 从视界左端起偏移多少像素开始贴
int blitWidth = width;
if (srcX < 0)
{
destOffset = -srcX;
blitWidth -= destOffset;
srcX = 0;
}
if (srcX + blitWidth > bitmapWidth)
{
blitWidth = bitmapWidth - srcX;
}
if (height > 0 && blitWidth > 0)
{
blit.dc = layer.dc;
blit.srcX = srcX;
blit.srcY = srcY;
blit.destX = static_cast<int>(std::lround(m_plotRect.X)) + destOffset;
blit.destY = static_cast<int>(std::lround(top)) + destTopOffset;
blit.width = blitWidth;
blit.height = height;
blits.push_back(blit);
// 横向 / 纵向只有**部分覆盖**:该网格的其余部分也要靠底衬补齐。
if (destOffset > 0 || blitWidth < width || height < bandHeight)
{
backing.push_back(static_cast<int>(index));
}
continue;
}
}
// ⚠️⚠️ **必须兜底补齐网格底衬,绝不能留白**:旧实现在这里直接 `continue`
//(网格空白)或用旧跨度像素 `StretchBlt` 拉伸占位(深度放大时把一小段像素放大
// 几十倍 → 整块网格变成一片模糊底色)—— 两者在用户眼里都是
// 「网格叠底消失,没有任何内容」。改为就地补画底衬(底色 + 网格线 + 分界线):
// 代价极低,可以每帧执行,因此**网格底衬永远不会空白/闪烁**(用户 2026-10-17 要求)。
backing.push_back(static_cast<int>(index));
}
}
if (blits.empty())
{
// ⚠️⚠️ **绝不能因为"本帧一个网格都贴不出图层像素"就退化成整幅重绘**
//(用户 2026-10-17 报告:滚动过渡动画在巨型信号处卡顿)。
// 这一情形在滚动到**从未构建过图层的区域**时必然出现(上百个网格时绝大多数
// 网格的图层都还没建立):旧实现在这里 `return false` ⇒ 落到 `Paint` 末尾的
// **整幅重绘** ⇒ 把**本帧全部可见网格的波形**(可能包含上千万采样的巨型信号)
// 每帧重画一遍 —— 实测单帧 **20~35ms**,而手动拖滚动条只要 ~6ms。
// 正确行为:本帧只画**网格底衬**(廉价,见下方),波形等视界真正停下之后再按预算补
//(这正是用户要求的“没来得及画信号时就按只有网格叠底的状态继续滚动”)。
if (backing.empty())
{
// 连底衬都没得画(布局变化 / 图层数组与网格数不符)→ 交给整幅重绘兜底。
return false;
}
}
// ⚠️ 顺序:**先画网格底衬(GDI+),再取得 HDC 做 BitBlt** ——
// `g.GetHDC()` 之后再调用 GDI+ 绘制接口是未定义行为。
for (const int laneIndex : backing)
{
PaintLaneGridBacking(g, laneIndex, timeTicks);
}
HDC target = g.GetHDC();
if (target == nullptr)
{
return true;
}
for (const LayerBlit& blit : blits)
{
BitBlt(target, blit.destX, blit.destY, blit.width, blit.height, blit.dc, blit.srcX, blit.srcY,
SRCCOPY);
}
g.ReleaseHDC(target);
return true;
}
} // namespace jdi