From 0efaa8c0ca3de8f3111c6e9b6ec66af2fe9e2bd5 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Sun, 18 Oct 2026 03:51:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=82=AC=E5=81=9C=E5=85=89=E6=99=95=20210ms/?= =?UTF-8?q?=E5=B8=A7=20=E2=86=92=204ms=EF=BC=88=E5=8C=85=E7=BB=9C=E5=B8=A6?= =?UTF-8?q?=E5=A1=AB=E5=85=85=20+=20=E5=85=89=E6=A0=87=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E8=A3=81=E5=89=AA=EF=BC=89+=20=E5=B9=B3=E7=A7=BB=E4=B8=A2?= =?UTF-8?q?=E6=B3=A2=E5=BD=A2=E5=88=A4=E6=8D=AE=E4=BF=AE=E6=AD=A3=20+=20?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E7=BB=84=E5=90=88=E5=8E=8B=E5=8A=9B=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、悬停巨型信号时整帧卡死(用户报: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(光标附近)、判据必须是同一次运行内的相对量、平移正确性只能与整幅重绘对比)。 --- Components/PlotView/Inc/PlotView.hpp | 9 +- Components/PlotView/Src/PlotViewInternal.hpp | 30 ++- Components/PlotView/Src/PlotViewPaint.cpp | 227 +++++++++++++++---- Documents/agent_mem.md | 72 ++++++ 4 files changed, 288 insertions(+), 50 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 676720c..4909b25 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -2764,9 +2764,11 @@ namespace jdi * @param [IN] resolved 已解析的信号。 * @param [IN] lane 该信号所在网格(单点信号需要它把数据点换算到屏幕坐标)。 * @param [IN] cached 该信号的折线缓存项(包络带模式下会走带外扩填充)。 + * @param [IN] windowCenterX 描边窗口中心(屏幕 x);仅当 `windowed` 为 true 时生效。 + * @param [IN] windowed 是否只描边窗口内的顶点(见 `kHaloWindowHalfWidth` 的说明)。 */ void PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const PolylineCacheEntry& cached); + const PolylineCacheEntry& cached, float windowCenterX, bool windowed); /** * @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。 @@ -2779,9 +2781,12 @@ namespace jdi * @param [IN] lane 该信号所在网格。 * @param [IN] points 折线顶点。 * @param [IN] breaks 每段折线的起始下标。 + * @param [IN] windowCenterX 描边窗口中心(屏幕 x);仅当 `windowed` 为 true 时生效。 + * @param [IN] windowed 是否只描边窗口内的顶点(见 `kHaloWindowHalfWidth` 的说明)。 */ void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& points, const std::vector& breaks); + const std::vector& points, const std::vector& breaks, + float windowCenterX, bool windowed); /** * @brief 单点信号那个数据点绘制时的半径(与散点绘制同源)。 diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index b2369f2..78dec07 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -78,15 +78,13 @@ namespace jdi inline const int kLayerRollQuantumX = 32; /** - * @brief 图层横向位移超过该值(位图像素)时就**作废图层**(而不是同步整块重画)。 + * @brief 单次「横向重新居中」的**最大**滚动量(位图像素)。 * - * ⚠️⚠️ 在前台同步重画一条**巨型信号**的图层实测 **20~130ms**; - * 拖拽时只要有一个“离屏累积了大位移、刚进入预取带”的网格就会卡一下。 - * 作废后本帧由 `BlitWideBuffer` 的**网格底衬**兜底(廉价),波形等视界停下后 - * 由 `RenderPendingLaneRebuilds` 按预算逐个补 —— 这正是用户要求的 - * “没来得及画信号时就按只有网格叠底的状态继续滚动”。 + * ⚠️ 滚动本身是廉价的(BitBlt + 补一条 `|roll|` 宽的竖带),但补条要重建那条带的 + * 折线/包络 ⇒ 代价与 `|roll|` 成正比。限制单次步长可以把“快速拖动”的补条代价 + * 摊到多帧;余额由 `BlitWideBuffer` 的源偏移承担(**不会空白**,也不会画错)。 */ - inline const int kLayerRollMaxShiftPixels = 160; + inline const int kLayerRollMaxStepPixels = 240; /** * @brief 每帧最多为多少个网格做**横向重新居中**(补细条)。 @@ -149,6 +147,24 @@ namespace jdi */ inline const float kMarkerTriangleGap = 4.0f; inline const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ + /** + * @brief 悬停光晕"描边窗口"的半宽(逻辑像素,使用时乘缩放)。 + * + * ⚠️ 光晕是**用加粗 GDI+ 折线描边**画出来的,而 GDI+ 描边的代价与 + * 「线段数 × 每段覆盖的行数」成正比:编码器/方波这类**满幅竖向锯齿**信号 + * 1 条 2503 顶点的折线实测要 **210ms/帧**(用户 2026-10-18 报告: + * 鼠标划过巨型信号的曲线时整帧卡死、浮动提示不再跟随鼠标)。 + * 因此只描边鼠标左右各 `kHaloWindowHalfWidth` 像素内的顶点: + * 顶点数降到几十个 ⇒ 单帧 <1ms,而"鼠标附近那条曲线发光"正是悬停提示该有的语义。 + */ + inline const float kHaloWindowHalfWidth = 72.0f; + /** + * @brief 顶点数达到该值才启用描边窗口裁剪。 + * + * 顶点少的信号(一屏内只有几百个采样)整条描边的代价本来就极小, + * 整条发光观感更好,因此不做裁剪。 + */ + inline const size_t kHaloWindowMinPoints = 360; inline const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */ inline const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ inline const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */ diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 601a2b8..3d94164 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -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(columns)); + // 下包络反向存储:列 c 在 `start + half + (half - 1 - c)`, + // 因此列区间 `[columnFirst, columnLast)` 对应一段**正向可读**的连续区间。 + bandPath.AddLines(cached.band.data() + start + half + (half - columnLast), + static_cast(columns)); + bandPath.CloseFigure(); + } + if (bandPath.GetPointCount() > 0) + { + gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x55))); + g.FillPath(&brush, &bandPath); + } + g.SetSmoothingMode(static_cast(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& points, - const std::vector& breaks) + const std::vector& 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(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(stop - start)); + if (!windowStroke) + { + g.DrawLines(&halo, points.data() + start, static_cast(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(runStop - runStart)); + } + runStart = stop; + runStop = stop; + } + } + if (runStart != stop && runStop > runStart + 1) + { + g.DrawLines(&halo, points.data() + runStart, static_cast(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(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(laneIndex)]; if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane)) { - PaintSignalHaloFor(g, resolved, lane, *cached); + // 图例悬停:鼠标就在图例条目上(网格左上角),以它为窗口中心。 + PaintSignalHaloFor(g, resolved, lane, *cached, static_cast(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(shift) / scale; + layer.wideMin += static_cast(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()) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 582256f..13a5aad 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -6040,3 +6040,75 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } - `uitest` **463/463**;`panperf` PASS(竖直中位 4.4ms、水平 7-10ms); `layercheck` `clobbers=0/24` + `pan frames wrong=0/4`;`cursorcheck` 30/30; - `make clean && make all` 零警告,`Build/Release/Target.exe` 已更新。 + +--- + +## 本轮(2026-10-18):悬停光晕 210ms/帧 → 4ms(探针 `panflash` / 新增 `stress`) + +用户报告三件事(VERY_BIG_DATA 与 MF4 都要测):① 快速中键拖拽时**所有网格的信号一起消失**; +② 组折叠/展开动画在鼠标移到"即将出现的信号行"上方时**卡顿甚至中断**;③ 悬停巨型信号(编码器A相)时 +**整帧卡死、浮动提示不再跟随鼠标**。 + +### A. ③ 的真凶:`PaintSignalHalo` 用**加粗 GDI+ 折线**描边整条曲线 + +`panflash` 探针复现(`hoverMean = 210.31ms` vs `baseline = 0.90ms`)。根因是"像素列包络带"信号的 +`points` 是**每列上/下包络交替的满幅竖锯齿**,GDI+ 描边代价 ≈ 线段数 × 覆盖行数(2503 顶点 × 1081 行)。 + +修法(`PlotViewPaint.cpp`): + +1. **包络带信号(`cached.bandMode`)**:光晕改为 `FillPath` 填充**与曲线本身同一几何**的包络带 + (`bandBreaks` 段 → 上包络正向 + 下包络反向),alpha 0x55; +2. **只画鼠标附近一段**(`kHaloWindowHalfWidth = 72` 逻辑像素、`kHaloWindowMinPoints = 360`): + - 包络带:把窗口换算成**列区间**再填充(下包络反向存储 ⇒ 列 c 在 `start+half+(half-1-c)`, + 列区间对应一段**正向可读**的连续区间); + - 折线描边:O(n) 扫一遍取窗口内的**连续顶点段**,段首尾各多含一个顶点(跨窗口的线段完整画出)。 +3. 窗口中心 = 鼠标 x(鼠标不在绘图区时取绘图区中心,保证列表悬停代价同样恒定)。 + +结果:**210ms → 4.0ms**(`baseline 0.9ms`),提示恢复跟手。几何正确性由"透明位图上两次绘制"探针确认: +窗口内的像素与整条光晕**逐像素相同**、窗口外 0 像素、且全部落在包络带内(`outsideBand=0`)。 + +### B. ② 是 ③ 的连带症状 + +列表悬停 → `Application::Render` 把 `panel.HoveredSignal()` 推给 `SetExternalHoverSignal` +→ 每帧(鼠标动一次就一次)重画光晕。以前悬停在 2500 万采样的编码器行上 = **210ms/帧**, +于是动画看着"卡顿甚至中断"(定时器被饿死,动画帧被拉长后跳变)。 +同一修复后 `[3b] listHover mean=0.72ms`(≈基线)。 + +### C. ① 的结论:主修复(`driftLimit` + 分帧滚动)**已经正确**,之前的 FAIL 是度量错了 + +`RollWideBuffer` 用 `kLayerRollMaxStepPixels=240` 每帧只把超限的位移往回滚最多 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`。 + +### D. 新增 `stress` 探针(随机组合压力测试,用户第 4 项要求) + +1 个 `VERY_BIG_DATA`(5×2576 万采样)+ 6 个 MF4 分组(共 ~1500 信号)、41 个可见网格; +种子固定的随机序列覆盖:中键拖拽(含空白判据抽样)、滚轮缩放 / 分布缩放、悬停扫掠、组折叠展开 +(**每帧都把鼠标扫过正在出现/消失的行**,并校验 `m_contentHeight` 单调)、适应视图、面板滚动条拖拽、 +勾选/取消信号、标签页新建/切换/关闭。判据全部**相对**(同一次运行内测基线), +避免机器状态导致的绝对阈值漂移。 + +实测:`ops=26 frames=209 mean=8.53ms worst=44.87ms (toggleSignal) overBudget=0`; +`pan blank check: 90 lane samples, 0 below 80%`;`group animation: heightRegressions=0`。 +最慢帧是 `toggleSignal`(勾选信号 ⇒ 41 个网格全部重画信号曲线像素),属于用户明确排除的"需要重绘曲线"场景。 + +### F. 验证(全部离屏,不占用鼠标/键盘/屏幕) + +- `panflash` **19/19**(平移无丢波形 + 悬停 4ms + 列表悬停 0.7ms + 光晕几何窗口化精确); +- `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` 被运行中的实例占用,不能直接覆盖)。 + +### G. 教训(新增规则) + +1. **GDI+ 的"加粗折线描边"绝不能用于逐像素列的包络带几何**:代价随"线段数 × 覆盖行数"爆炸; + 稠密信号的光晕/高亮一律用 `FillPath`(同一几何)+ 鼠标附近窗口裁剪。 +2. **任何"每帧随鼠标变化"的绘制都必须 O(光标附近)**,不能 O(整条曲线);否则一次悬停就能把 60fps 打成 5fps。 +3. **性能与正确性判据必须是"同一次运行内的相对量"**:绝对 ms / 绝对墨迹都会随机器状态和视界内容变化而误报。 +4. 平移正确性的唯一可信判据是"与同一视界的整幅重绘对比",而不是"与某个固定基准帧对比"。