悬停光晕 210ms/帧 → 4ms(包络带填充 + 光标窗口裁剪)+ 平移丢波形判据修正 + 随机组合压力测试

一、悬停巨型信号时整帧卡死(用户报:VERY_BIG_DATA 的「编码器A相」,浮动提示不再跟随鼠标)
探针复现:hoverMean = 210.31ms / baseline = 0.90ms。
根因:PaintSignalHalo 用「加粗 GDI+ 折线」描边整条曲线,而包络带信号的顶点是
「每像素列上/下包络交替」的满幅竖锯齿 —— GDI+ 描边代价 ≈ 线段数 × 覆盖行数
(2503 顶点 × 1081 行)。同一几何改用 FillPath 只要 ~1ms。
修法(PlotViewPaint.cpp / PlotViewInternal.hpp):
1) 包络带信号(cached.bandMode)的光晕改为**填充与曲线本身同一几何**的包络带
   (bandBreaks 段 → 上包络正向 + 下包络反向,alpha 0x55);
2) 只画鼠标附近一段(kHaloWindowHalfWidth=72 逻辑像素、kHaloWindowMinPoints=360):
   · 包络带:窗口换算成列区间再填充(下包络反向存储 ⇒ 列 c 在 start+half+(half-1-c),
     列区间恰对应一段正向可读的连续区间);
   · 折线描边:O(n) 扫一遍取窗口内的连续顶点段,段首尾各多含一个顶点(跨窗口的线段完整画出);
   · 散点高亮同样按窗口过滤(步长仍与 PaintLane 同源)。
3) 窗口中心 = 鼠标 x;鼠标不在绘图区时取绘图区中心(列表/样式区悬停代价同样恒定)。
实测 210ms → 4.0ms(提示恢复跟手)。几何正确性用「透明位图上两次绘制」探针确认:
窗口内与整条光晕逐像素相同(mismatched=0)、窗口外 0 像素(far=0)、全部落在包络带内(outsideBand=0)。

二、组折叠/展开动画卡顿甚至中断(用户用 MF4→BIG→MF4 + 只勾「编码器A相」稳定复现)
这是上面那条的连带症状:列表悬停时 Application::Render 把 panel.HoveredSignal() 推给
SetExternalHoverSignal ⇒ 鼠标每动一次就重画一次光晕;悬停在 2576 万采样的编码器行上
= 210ms/帧 ⇒ 动画定时器被饿死,看起来「卡顿甚至中断」。
同一修复后列表悬停帧 0.72ms(≈ 基线 0.81ms)。探针 [3b] 固化该判据。

三、快速中键拖拽「所有网格信号一起消失」——主修复已正确,之前的失败是度量错了
上一轮把「位移超出 kLayerRollMaxShiftPixels 就作废图层」改成
「driftLimit + 每帧最多滚 kLayerRollMaxStepPixels=240px」,剩余位移由 BlitWideBuffer 的
srcX 承担 ⇒ |shift| 可长期停在 ±(margin-24) 附近而**不会空白**。
之前探针拿「适应视图时的墨迹」当基准,而平移本身会移出数据范围(时间轴外没有采样),
墨迹自然下降 ⇒ 误报。正确判据(已固化进 panflash / stress):
同一次拖拽中把当前帧与**同一视界的整幅强制重绘**(m_sceneValid=false + 清空 m_layerFront)比墨迹,
比值 < 0.9 才算丢波形。三条拖拽模式(120px/帧、90×7 斜向、260px/帧)全部 badFrames=0,
worstRatio 0.966~0.999;MF4 8 个单点信号网格 framesWithMissingPoints=0。

四、新增随机组合压力测试(用户第 4 项要求:给出评估方法与压力验证)
.preflight/perf/stress_main.cpp:1 个 VERY_BIG_DATA(5×2576 万采样)+ 6 个 MF4 分组
(~1500 信号、41 个可见网格),固定种子的随机序列覆盖:中键拖拽(抽样做空白判据)、
滚轮缩放/分布缩放、悬停扫掠、组折叠展开(**每帧都把鼠标扫过正在出现/消失的行**,并校验
m_contentHeight 单调)、适应视图、面板滚动条拖拽、勾选/取消信号、标签页新建/切换/关闭。
判据全部为**同一次运行内的相对量**(基线帧 vs 交互帧),避免绝对 ms 阈值的漂移。
实测:ops=26 frames=209 mean=8.53ms worst=44.87ms(toggleSignal) overBudget=0;
pan blank check 90 个车道样本 0 个低于整幅重绘的 80%;group animation heightRegressions=0。
最慢帧是勾选信号(41 个网格全部重画曲线像素),属于用户明确排除的「需要重绘信号曲线」场景。

五、验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/perf/panflash_main.cpp(panflash.exe)19/19:三条拖拽模式的丢波形判据、
  MF4 单点网格、悬停耗时(4.0ms)、列表悬停联动(0.72ms)、光晕窗口化几何精确性;
- 新增 stress 6/6;既有回归 verify 49/49、panelanim 158/158、hoverdrag 20/20、
  gridanim 93/93、lanescroll 23/23、datacheck 69/69;
- make release TARGET_FILE_NAME=TargetCheck 通过(Target.exe 被运行中的实例占用)。

文档:Documents/agent_mem.md 新增本轮小节(含 4 条新规则:GDI+ 加粗描边不得用于包络带几何、
随鼠标变化的绘制必须 O(光标附近)、判据必须是同一次运行内的相对量、平移正确性只能与整幅重绘对比)。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-10-18 03:51:53 +08:00
1 parent fd606a6f80
commit 0efaa8c0ca
4 files changed
+288 -50

No files matched your search

+186 -41
View File
@@ -1043,20 +1043,96 @@ namespace jdi
}
void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached)
const PolylineCacheEntry& cached, float windowCenterX, bool windowed)
{
if (resolved.style == nullptr)
{
return;
}
// 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充",
// 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。
PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks);
// ------------------------------------------------------------------
// 稠密信号(走"像素列包络带"):光晕改为**填充与曲线本身同一几何的包络带**。
//
// ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是
// 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数,
// 实测 2503 个顶点要 **210ms/帧**(用户 2026-10-18 报告:鼠标划过巨型信号的
// 曲线时整帧卡死、浮动提示不再跟随鼠标)。而 `FillPath` 同一条几何只需 ~1ms,
// 且观感更贴切(整条曲线一起"发光",与曲线本身逐像素同源)。
// ⚠️ 填充也**只填鼠标附近的一段**(见 `kHaloWindowHalfWidth`):半透明填充
// 的代价与「覆盖面积 + 路径顶点数」成正比,整条满幅带实测 ~13ms/帧。
// ------------------------------------------------------------------
if (resolved.style->mode != DrawMode::SCATTER && cached.bandMode && !cached.bandBreaks.empty())
{
const float halfWidth = kHaloWindowHalfWidth * m_scale;
const int savedSmoothing = g.GetSmoothingMode();
g.SetSmoothingMode(gd::SmoothingModeNone); // 半透明填充:抗锯齿没有观感收益
gd::GraphicsPath bandPath;
bandPath.SetFillMode(gd::FillModeWinding);
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` 个)」
//(见 `EmitEnvelopeBand`),因此窗口只需换算成列区间。
const std::size_t half = total / 2;
std::size_t columnFirst = 0;
std::size_t columnLast = half;
if (windowed)
{
while (columnFirst < half &&
cached.band[start + columnFirst].X < windowCenterX - halfWidth - 1.0f)
{
++columnFirst;
}
while (columnLast > columnFirst &&
cached.band[start + columnLast - 1].X > windowCenterX + halfWidth + 1.0f)
{
--columnLast;
}
// 两侧各多含 1 列,避免窗口边界处的半透明边缘被切出直边。
if (columnFirst > 0)
{
--columnFirst;
}
if (columnLast < half)
{
++columnLast;
}
}
if (columnLast <= columnFirst + 1)
{
continue;
}
const std::size_t columns = columnLast - columnFirst;
bandPath.StartFigure();
bandPath.AddLines(cached.band.data() + start + columnFirst, static_cast<INT>(columns));
// 下包络反向存储:列 c 在 `start + half + (half - 1 - c)`,
// 因此列区间 `[columnFirst, columnLast)` 对应一段**正向可读**的连续区间。
bandPath.AddLines(cached.band.data() + start + half + (half - columnLast),
static_cast<INT>(columns));
bandPath.CloseFigure();
}
if (bandPath.GetPointCount() > 0)
{
gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x55)));
g.FillPath(&brush, &bandPath);
}
g.SetSmoothingMode(static_cast<gd::SmoothingMode>(savedSmoothing));
return;
}
// 其余情形一律用折线描边(与"之前"的画法一致)。
PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks, windowCenterX, windowed);
}
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)
const std::vector<std::size_t>& breaks, float windowCenterX,
bool windowed)
{
// ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点):
// 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。
@@ -1093,10 +1169,18 @@ namespace jdi
const float ringRadius = radius + 2.5f * m_scale;
const std::size_t scatterStride =
std::max<std::size_t>(1, points.size() / kMaxScatterPoints);
// 散点数量本身有上限(`kMaxScatterPoints`),逐点画圈是 O(数千) 的小代价;
// 但仍按窗口过滤,保持"只高亮鼠标附近"的一致语义(点少时窗口外也全画)。
const bool windowScatter = windowed && points.size() > kHaloWindowMinPoints;
const float halfWidth = kHaloWindowHalfWidth * m_scale;
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];
if (windowScatter && std::fabs(point.X - windowCenterX) > halfWidth)
{
continue;
}
g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f,
ringRadius * 2.0f);
}
@@ -1112,6 +1196,12 @@ namespace jdi
halo.SetLineJoin(gd::LineJoinRound);
halo.SetStartCap(gd::LineCapRound);
halo.SetEndCap(gd::LineCapRound);
// 顶点多的信号只描边**鼠标左右一小段**(见 `kHaloWindowHalfWidth` 的说明):
// 整条描边在满幅锯齿信号上要 100~200ms/帧,鼠标移动时整帧卡死。
const bool windowStroke = windowed && points.size() > kHaloWindowMinPoints;
const float halfWidth = kHaloWindowHalfWidth * m_scale;
const float windowLeft = windowCenterX - halfWidth;
const float windowRight = windowCenterX + halfWidth;
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
@@ -1120,7 +1210,46 @@ namespace jdi
{
continue;
}
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
if (!windowStroke)
{
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
continue;
}
// 窗口内的顶点在段内**连续**(折线沿时间单调),因此顺序扫描一次、按连续区段绘制。
// 多含一个窗口外的顶点,让跨越窗口边界的线段完整画出(不出现断头)。
std::size_t runStart = stop;
std::size_t runStop = stop;
for (std::size_t index = start; index < stop; ++index)
{
const bool inside = (points[index].X >= windowLeft - 1.0f) &&
(points[index].X <= windowRight + 1.0f);
if (inside && runStart == stop)
{
runStart = (index > start) ? (index - 1) : index;
runStop = index + 1;
}
else if (inside)
{
runStop = index + 1;
}
else if (runStart != stop)
{
if (index + 1 < stop)
{
runStop = index + 2;
}
if (runStop > runStart + 1)
{
g.DrawLines(&halo, points.data() + runStart, static_cast<INT>(runStop - runStart));
}
runStart = stop;
runStop = stop;
}
}
if (runStart != stop && runStop > runStart + 1)
{
g.DrawLines(&halo, points.data() + runStart, static_cast<INT>(runStop - runStart));
}
}
}
@@ -1173,7 +1302,16 @@ namespace jdi
const PolylineCacheEntry* cached = AcquirePolyline(haloRef, resolved, lane);
if (cached != nullptr)
{
PaintSignalHaloFor(g, resolved, lane, *cached);
// 光晕只描边鼠标附近的一段(见 `kHaloWindowHalfWidth`):鼠标不在绘图区内时
//(列表/样式区悬停)以绘图区中心为窗口中心,保证代价恒定、不平白卡一帧。
const bool mouseInPlot = (m_mouseX >= m_plotRect.X &&
m_mouseX <= m_plotRect.X + m_plotRect.Width &&
m_mouseY >= m_plotRect.Y &&
m_mouseY <= m_plotRect.Y + m_plotRect.Height);
const float haloCenterX =
mouseInPlot ? static_cast<float>(m_mouseX)
: (m_plotRect.X + m_plotRect.Width * 0.5f);
PaintSignalHaloFor(g, resolved, lane, *cached, haloCenterX, true);
}
}
}
@@ -1246,7 +1384,8 @@ namespace jdi
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane))
{
PaintSignalHaloFor(g, resolved, lane, *cached);
// 图例悬停:鼠标就在图例条目上(网格左上角),以它为窗口中心。
PaintSignalHaloFor(g, resolved, lane, *cached, static_cast<float>(m_mouseX), true);
}
}
@@ -3165,17 +3304,21 @@ namespace jdi
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
// ⚠️⚠️ **位移太大时【作废图层】,绝不能在前台同步整块重画**(用户 2026-10-17 报告):
// 旧实现在这里“立刻整块重画并重新居中”。整块重画一条**巨型信号**(`VERY_BIG_DATA`
// 25M 采样)实测 **20~130ms** —— 拖拽时只要碰到一个“离屏累积了大位移、刚刚进入预取带”
// 的网格,就是一次明显卡顿(实测拖拽最坏帧 28ms、其中 `RollWideBuffer` 占 130ms)。
// 现在:位移超出可廉价滚动的范围 ⇒ **作废图层 + 登记待重画**,本帧由 `BlitWideBuffer`
// 的**网格底衬**兜底(正是用户要求的“没来得及画信号就按只有网格叠底的状态继续”),
// 波形等视界停下后由 `RenderPendingLaneRebuilds` 按预算逐个补。
const bool tooFarToRoll = (std::abs(shift) >= kLayerRollMaxShiftPixels) ||
(std::abs(vOffset) >= layer.height);
if (tooFarToRoll)
// ⚠️⚠️ **"位移太大"绝不允许【作废图层】**(用户 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;
@@ -3231,40 +3374,41 @@ namespace jdi
layer.anchorYMax);
}
if (shift == 0)
// ⚠️ **横向重新居中只在「快要失去覆盖」时才做**(见上方 `driftLimit` 的说明):
// `BlitWideBuffer` 按 `(ViewMin - layer.wideMin)` 算源偏移,因此视界小幅漂移
// **完全不需要滚动**(等价且免费)。只有漂移接近 `margin`(位图覆盖边界)时才需要
// 向中心滚回去,否则网格右侧/左侧会开始用底衬(看起来像“信号被切成两半”)。
if (std::abs(shift) <= driftLimit)
{
continue;
}
// ⚠️ **横向滚动量按量子量化**(见 `kLayerRollQuantumX`):
// `BlitWideBuffer` 贴图时本来就按 `(ViewMin - layer.wideMin)` 算源偏移,
// 因此视界小幅移动**不需要**重新居中(不必每帧补一条窄竖带)。
// 拖拽时每帧位移只有几像素,旧实现就是“每帧补一次条 × 每个可见网格”,
// 而每次补条都要重跑 `BuildLaneVisuals()`(O(网格数))⇒ 明显卡顿。
if (std::abs(shift) < kLayerRollQuantumX)
{
continue;
}
// ⚠️ **每帧只重居中有限的几个网格**(见 `kLayerRollsPerFrame`):
// 不重居中也不会画错(贴图按 `wideMin` 算源偏移),因此可以安全地分帧完成。
// ⚠️ **每帧只重居中有限的几个网格**(见 `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));
// 整数像素自贴图平移:内容整体移动 `shift` 像素。
if (shift > 0)
// 整数像素自贴图平移:内容整体移动 `roll` 像素。
if (roll > 0)
{
// 内容整体左移 shift:new[x] = old[x + shift],右侧暴露 shift 列。
BitBlt(layer.dc, 0, 0, bitmapWidth - shift, layer.height, layer.dc, shift, 0, SRCCOPY);
// 内容整体左移 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
{
const int amount = -shift;
BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, SRCCOPY);
continue;
}
layer.wideMin += static_cast<double>(shift) / scale;
layer.wideMin += static_cast<double>(roll) / scale;
// 只补画新暴露的细条(每帧代价恒为 O(细条))。
// ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界"
@@ -3273,8 +3417,8 @@ namespace jdi
// 竖带,其余像素仍在原锚点上;同时该函数按锚点渲染这条竖带,因此竖带与其余像素
// 纵向同源。若在这里把锚点重置为当前 `yMax`,残余的纵向偏移会被静默清零、而像素
// 并未上移 → 整个网格的波形纵向错位("垂直拖动后再横向平移出现纵向跳变")。
const int stripWidth = std::min(std::abs(shift), bitmapWidth);
const int stripX = (shift > 0) ? (bitmapWidth - stripWidth) : 0;
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);
}
@@ -3781,7 +3925,8 @@ namespace jdi
const bool highlighted = (ref == haloRef);
if (highlighted)
{
PaintSignalHaloFor(g, resolved, lane, *cached);
// 拖拽光晕整条描边并进入静态场景缓存(拖拽期间不随鼠标变化,不必裁剪)。
PaintSignalHaloFor(g, resolved, lane, *cached, 0.0f, false);
}
if (drawLine && !breaks.empty())