diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 5913d27..b48e374 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -434,6 +434,11 @@ namespace jdi // 鼠标离开窗口(WM_MOUSELEAVE)、数据/行结构变化、切换标签页等。 m_plot.SetExternalHoverSignal(m_panel.HoveredSignal()); + // ⚠️ 面板动画期间**暂停绘图区的网格图层预热**:单个巨型信号的图层重建要 10~30ms, + // 塞进动画帧就会让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。 + // 预热只是被推迟(待重画队列保留),动画结束后由既有预算继续补齐。 + m_plot.SetAnimationBusy(panelAnimating || panelOnly); + if (fullChrome) { UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 4909b25..1878df7 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1067,7 +1067,22 @@ namespace jdi */ void RefreshLanes(); + /** + * @brief 设置「前台正在播放 UI 动画」标志(信号列表滚动 / 分组折起展开动画)。 + * + * ⚠️ 为 true 时**暂停网格图层预热**(`WarmLaneLayersStep`):预热每帧要重画 1 个网格图层, + * 而单个巨型信号(2500 万采样)的图层重建实测 **10~30ms** —— 它会直接把 60fps 的 + * 动画打成一帧一跳(用户 2026-10-18 报告:信号列表只有文字,滚动/展开却明显卡顿)。 + * 动画结束后预热会自然继续(待重画队列不丢),因此只是"推迟",不改变最终画面。 + * @param [IN] busy 是否正在播放动画。 + */ + void SetAnimationBusy(bool busy) { m_animationBusy = busy; } + private: + /** @brief 前台是否正在播放 UI 动画(见 `SetAnimationBusy`)。 */ + bool m_animationBusy = false; + + public: /** * @brief 单个网格的绘制信息。 */ @@ -2764,11 +2779,87 @@ 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, float windowCenterX, bool windowed); + const PolylineCacheEntry& cached); + + /** + * @brief 绘制**悬停**光晕:把整条光晕渲染进一张离屏位图并缓存,命中时只贴图。 + * + * ⚠️ 悬停光晕必须**沿整条曲线**发光(用户 2026-10-18 第二轮报告:上一版只画 + * 鼠标左右 ±72px 的一段,在满幅方波上看起来就是一个**长方形色块**,像选区框)。 + * 而整条光晕的直接绘制代价很高(稠密包络带填充实测 ~14ms/帧,加粗折线描边更是 + * 200ms+/帧)。折中方案:光晕的几何只取决于「信号 + 视图 + 网格几何 + 样式」, + * **与鼠标位置无关**,因此渲染一次进离屏位图后每帧只贴图(~0.5ms), + * 并随视图/几何/样式变化自动失效重建。 + * + * @param [IN,OUT] g 绘图上下文(调用方已设置绘图区裁剪)。 + * @param [IN] resolved 已解析的信号。 + * @param [IN] lane 该信号所在网格。 + * @param [IN] cached 该信号的折线缓存项。 + */ + void PaintSignalHaloCached(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, const PolylineCacheEntry& cached); + + /** + * @brief 悬停光晕的离屏缓存(见 `PaintSignalHaloCached`)。 + * + * 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源), + * 唯独**不含网格的屏幕纵向位置**:滚动网格时只需按新位置贴同一张位图。 + */ + struct HaloLayerCache + { + bool valid = false; /**< 位图内容是否有效。 */ + SignalRef ref; /**< 信号引用。 */ + const SignalGroup* group = nullptr; /**< 所属分组。 */ + std::size_t sampleCount = 0; /**< 采样点数。 */ + double viewMin = 0.0; /**< 时间轴显示下界。 */ + double viewMax = 0.0; /**< 时间轴显示上界。 */ + float plotX = 0.0f; /**< 绘图区左边界。 */ + float plotWidth = 0.0f; /**< 绘图区宽度。 */ + float laneHeight = 0.0f; /**< 网格高度。 */ + double laneYMin = 0.0; /**< 网格幅值下界。 */ + double laneYMax = 0.0; /**< 网格幅值上界。 */ + float lineWidth = 1.0f; /**< 线宽。 */ + DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */ + float gain = 1.0f; /**< 绘制系数。 */ + bool absolute = false; /**< 是否绝对值。 */ + bool mirrorVertical = false; /**< 是否垂直镜像。 */ + bool mirrorHorizontal = false; /**< 是否水平镜像。 */ + float offsetY = 0.0f; /**< Y 偏移。 */ + double offsetTime = 0.0; /**< 时间偏移。 */ + int originX = 0; /**< 位图左上角(屏幕 x)。 */ + int originY = 0; /**< 位图左上角(屏幕 y)。 */ + std::unique_ptr bitmap; /**< 32bpp ARGB 光晕图层(直通 alpha)。 */ + + /** + * @brief 预乘 alpha 的 DIB 副本(每帧真正贴出去的那张)。 + * + * ⚠️ 每帧贴图必须走 GDI `AlphaBlend` + `AC_SRC_ALPHA`: + * `gd::Graphics::DrawImage` 贴一张 32bpp 半透明位图实测约 **6ms**(每帧都付), + * 而 `AlphaBlend` 对同一张图约 **0.3ms**。 + * `AlphaBlend` 要求源位图的 RGB **已按 alpha 预乘**,因此重建时把 + * `bitmap` 拷进这个 DIB 并顺带做预乘(只付一次)。 + */ + HDC dc = nullptr; + HBITMAP dib = nullptr; + void* bits = nullptr; + int dibWidth = 0; + int dibHeight = 0; + }; + + mutable HaloLayerCache m_haloLayer; + + /** @brief 释放悬停光晕的离屏位图(析构 / 重建尺寸时使用)。 */ + void ReleaseHaloLayer(); + + /** + * @brief 把 `m_haloLayer.bitmap` 拷进预乘 alpha 的 DIB(必要时先创建/重建 DIB)。 + * @param [IN] width 需要的位图宽度。 + * @param [IN] height 需要的位图高度。 + * @return DIB 可用返回 true。 + */ + bool UploadHaloLayer(int width, int height); /** * @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。 @@ -2781,12 +2872,9 @@ 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, - float windowCenterX, bool windowed); + const std::vector& points, const std::vector& breaks); /** * @brief 单点信号那个数据点绘制时的半径(与散点绘制同源)。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index fafd2ef..878860d 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -26,6 +26,7 @@ namespace jdi ReleaseDecorCache(); ReleaseWideBuffer(); ReleasePanelCache(); + ReleaseHaloLayer(); } void PlotView::Setup(const UiFonts* fonts, const Theme* theme) diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 78dec07..30f7e4a 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -148,23 +148,15 @@ namespace jdi inline const float kMarkerTriangleGap = 4.0f; inline const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ /** - * @brief 悬停光晕"描边窗口"的半宽(逻辑像素,使用时乘缩放)。 + * @brief 悬停光晕缓存中「关闭抗锯齿」的顶点数阈值。 * - * ⚠️ 光晕是**用加粗 GDI+ 折线描边**画出来的,而 GDI+ 描边的代价与 - * 「线段数 × 每段覆盖的行数」成正比:编码器/方波这类**满幅竖向锯齿**信号 - * 1 条 2503 顶点的折线实测要 **210ms/帧**(用户 2026-10-18 报告: - * 鼠标划过巨型信号的曲线时整帧卡死、浮动提示不再跟随鼠标)。 - * 因此只描边鼠标左右各 `kHaloWindowHalfWidth` 像素内的顶点: - * 顶点数降到几十个 ⇒ 单帧 <1ms,而"鼠标附近那条曲线发光"正是悬停提示该有的语义。 + * 光晕是**沿整条曲线**画出来的(不能只画鼠标附近的一段:在满幅方波上那看起来 + * 就是一个长方形色块,像选区框 —— 用户 2026-10-18 第二轮报告)。它的代价只 + * 在「光晕缓存重建」时付一次,因此通常可直接用抗锯齿;但顶点很多的区间 + * (深放大后的方波:每段都跨满网格高)GDI+ 抗锯齿的扫描线代价 ≈ 线段数 × 覆盖行数, + * 实测可差 20 倍以上。这类密集情形关闭抗锯齿(几何完全不变,半透明光晕上看不出差别)。 */ - inline const float kHaloWindowHalfWidth = 72.0f; - /** - * @brief 顶点数达到该值才启用描边窗口裁剪。 - * - * 顶点少的信号(一屏内只有几百个采样)整条描边的代价本来就极小, - * 整条发光观感更好,因此不做裁剪。 - */ - inline const size_t kHaloWindowMinPoints = 360; + inline const size_t kHaloNoAaMinPoints = 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 3d94164..39efd54 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -1043,26 +1043,25 @@ namespace jdi } void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const PolylineCacheEntry& cached, float windowCenterX, bool windowed) + const PolylineCacheEntry& cached) { if (resolved.style == nullptr) { return; } // ------------------------------------------------------------------ - // 稠密信号(走"像素列包络带"):光晕改为**填充与曲线本身同一几何的包络带**。 + // 稠密信号(走"像素列包络带"):光晕 = **填充与曲线本身同一几何**的包络带。 // // ⚠️ 绝不能用「加粗折线描边」画稠密信号的光晕:包络带的顶点序列是 // 「每列上/下包络交替」的满幅竖锯齿,GDI+ 描边的代价 = 线段数 × 覆盖行数, - // 实测 2503 个顶点要 **210ms/帧**(用户 2026-10-18 报告:鼠标划过巨型信号的 - // 曲线时整帧卡死、浮动提示不再跟随鼠标)。而 `FillPath` 同一条几何只需 ~1ms, - // 且观感更贴切(整条曲线一起"发光",与曲线本身逐像素同源)。 - // ⚠️ 填充也**只填鼠标附近的一段**(见 `kHaloWindowHalfWidth`):半透明填充 - // 的代价与「覆盖面积 + 路径顶点数」成正比,整条满幅带实测 ~13ms/帧。 + // 实测 2503 个顶点要 **210ms**。而 `FillPath` 同一条几何只要 ~14ms, + // 且观感与原实现(沿折线描粗)一致:整条曲线一起"发光"。 + // ⚠️ 光晕**必须覆盖整条曲线**:曾经只画鼠标附近 ±72px 的一段,在满幅方波上 + // 看起来就是一个**长方形色块**(像选区框),用户 2026-10-18 第二轮明确报告。 + // 代价问题改由 `PaintSignalHaloCached`(渲染一次 + 每帧贴图)解决。 // ------------------------------------------------------------------ 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; @@ -1077,44 +1076,11 @@ namespace jdi { continue; } - // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」 - //(见 `EmitEnvelopeBand`),因此窗口只需换算成列区间。 + // 段内布局 = 「上包络正向(`half` 个)+ 下包络反向(`half` 个)」。 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.AddLines(cached.band.data() + start, static_cast(half)); + bandPath.AddLines(cached.band.data() + start + half, static_cast(half)); bandPath.CloseFigure(); } if (bandPath.GetPointCount() > 0) @@ -1126,13 +1092,231 @@ namespace jdi return; } // 其余情形一律用折线描边(与"之前"的画法一致)。 - PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks, windowCenterX, windowed); + 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(std::floor(left)); + const int targetY = static_cast(std::floor(top)); + const int width = static_cast(std::ceil(right)) - targetX; + const int height = static_cast(std::ceil(bottom)) - targetY; + if (width <= 0 || height <= 0) + { + return; + } + + HaloLayerCache& cache = m_haloLayer; + // 键 = 决定光晕几何的全部输入(与 `AcquirePolyline` 的键同源)。 + // ⚠️ **不含网格的屏幕位置**:滚动网格时几何不变,只需按新位置贴同一张位图。 + const bool sameSize = cache.bitmap && static_cast(cache.bitmap->GetWidth()) == width && + static_cast(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(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(-targetX), + static_cast(-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。 + // 用 256×256 查表代替逐像素除法(1.6M 像素时可省 ~5ms)。 + static std::uint8_t premultiply[256 * 256]; + static bool premultiplyReady = false; + if (!premultiplyReady) + { + for (int alpha = 0; alpha < 256; ++alpha) + { + for (int value = 0; value < 256; ++value) + { + premultiply[(alpha << 8) | value] = + static_cast((value * alpha + 127) / 255); + } + } + premultiplyReady = true; + } + 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(cache.bits); + for (int y = 0; y < height; ++y) + { + const unsigned char* source = + static_cast(data.Scan0) + static_cast(y) * data.Stride; + unsigned char* row = destination + static_cast(y) * width * 4u; + for (int x = 0; x < width; ++x) + { + const int alpha = source[x * 4 + 3]; + const int table = alpha << 8; + row[x * 4 + 0] = premultiply[table | source[x * 4 + 0]]; + row[x * 4 + 1] = premultiply[table | source[x * 4 + 1]]; + row[x * 4 + 2] = premultiply[table | source[x * 4 + 2]]; + row[x * 4 + 3] = static_cast(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& points, - const std::vector& breaks, float windowCenterX, - bool windowed) + const std::vector& breaks) { // ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点): // 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。 @@ -1169,18 +1353,11 @@ 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; + // 散点数量本身有上限(`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]; - if (windowScatter && std::fabs(point.X - windowCenterX) > halfWidth) - { - continue; - } g.DrawEllipse(&ring, point.X - ringRadius, point.Y - ringRadius, ringRadius * 2.0f, ringRadius * 2.0f); } @@ -1196,12 +1373,6 @@ 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]; @@ -1210,46 +1381,7 @@ namespace jdi { continue; } - 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)); - } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); } } @@ -1302,16 +1434,9 @@ namespace jdi const PolylineCacheEntry* cached = AcquirePolyline(haloRef, resolved, lane); if (cached != nullptr) { - // 光晕只描边鼠标附近的一段(见 `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); + // 整条光晕一次性渲进离屏位图并缓存(命中时只贴图,~0.5ms/帧), + // 因此光晕**沿整条曲线**发光而不随鼠标移动(见 `PaintSignalHaloCached`)。 + PaintSignalHaloCached(g, resolved, lane, *cached); } } } @@ -1384,8 +1509,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, static_cast(m_mouseX), true); + // 图例悬停:整条曲线一起发光(与鼠标命中的光晕完全同一种画法与代价)。 + PaintSignalHaloCached(g, resolved, lane, *cached); } } @@ -2729,6 +2854,18 @@ namespace jdi [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(m_layerRebuildPending.size()); const int take = std::min(budget, available); std::vector batch; @@ -3190,6 +3327,12 @@ namespace jdi { return; } + // ⚠️ **UI 动画期间绝不预热**:单个巨型信号的图层重建要 10~30ms,塞进动画帧就会 + // 让"只有文字的信号列表"看起来卡顿(用户 2026-10-18 报告)。队列保留,动画结束后继续。 + if (m_animationBusy) + { + return; + } if (!(m_plotRect.Width > 8.0f) || !(Tab().ViewMax() - Tab().ViewMin() > 0.0)) { return; @@ -3288,12 +3431,13 @@ namespace jdi // `BuildLaneVisuals()`(O(网格数))—— 253 个网格时这是拖拽卡顿的主因。 // 只登记待重画(可视网格会被 `RenderPendingLaneRebuilds` 优先处理), // 等它们真的要进入视区时再按新视界重建(期间由 `BlitWideBuffer` 的底衬兜底)。 + // + // ⚠️⚠️ **离屏网格的"内容过期"也不登记待重画**:否则一次"网格数变化"(勾选/取消 + // 一个分组)就会把**上百个离屏网格**全部塞进队列,随后每帧补 1 个 × 10~30ms + // ⇒ 数秒的持续掉帧(用户 2026-10-18 报告的信号列表动画卡顿有一半来自这里)。 + // 离屏网格真正进入可视/预取带时,下面的可见分支会按新视界登记它。 if (!LaneVisuallyRelevant(laneIndex, 1)) { - if (contentStale) - { - QueueLaneRebuild(laneIndex); - } continue; } // 纵向:幅值**平移**不改内容,只改贴图偏移(与横向同构)。 @@ -3926,7 +4070,7 @@ namespace jdi if (highlighted) { // 拖拽光晕整条描边并进入静态场景缓存(拖拽期间不随鼠标变化,不必裁剪)。 - PaintSignalHaloFor(g, resolved, lane, *cached, 0.0f, false); + PaintSignalHaloFor(g, resolved, lane, *cached); } if (drawLine && !breaks.empty()) diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 23cfa78..9daa747 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -806,6 +806,76 @@ namespace jdi */ void PaintSignalRow(gd::Graphics& g, int row); + /** + * @brief 把一行(分组行 / 信号行)**绘制进离屏位图缓存**,再整块贴到列表上。 + * + * ⚠️ 一行的绘制要调用十来个 GDI+ 原语(圆角托底 / 复选框 / 色块 / 两段文字…), + * 实测 **0.35ms/行**;列表一屏能放 35~40 行 ⇒ 单帧 **12~15ms** 全花在重复绘制 + * **内容完全没变**的行上(分组折起/展开动画只是在整行平移)。 + * 因此按"行的可见内容"做键缓存位图:动画帧只需把命中的位图按新位置贴出来。 + * + * ⚠️ 缓存期间行的绘制**不能**裁到列表矩形(`m_rowClipRender`):位图是整行内容, + * 裁剪由贴图时的 GDI+ 裁剪区负责 —— 否则部分滚出上边界的行会永久丢掉顶部像素。 + * + * @param [IN,OUT] g 绘图上下文(调用方已把裁剪区设为列表矩形)。 + * @param [IN] row 行下标。 + */ + void PaintRowCached(gd::Graphics& g, int row); + + /** + * @brief 计算一行的缓存键(涵盖影响该行像素的全部状态)。 + * @param [IN] row 行下标。 + * @return 键值。 + */ + std::uint64_t RowCacheKey(int row) const; + + /** @brief 作废全部行位图缓存(行集合 / 字体 / 主题变化时调用)。 */ + void InvalidateRowCache(); + + /** @brief 一行的离屏位图缓存项。 */ + struct RowBitmapCache + { + /** + * @brief 该行的像素(DIB 内存 DC,已铺面板底色)。 + * + * ⚠️ 必须是 **DIB + 内存 DC**,不能用 `gd::Bitmap`:GDI 在 GDI+ 位图的临时 HDC 上 + * 会**关闭 ClearType**(退化为灰度抗锯齿),列表文字因此变细、观感与正文不一致 + *(实测:整列文字丢失亚像素渲染)。DIB 内存 DC 上 ClearType 与屏幕一致。 + */ + HDC dc = nullptr; + HBITMAP dib = nullptr; + std::uint64_t key = 0; /**< 内容键(不一致即重建)。 */ + int width = 0; /**< 位图宽度(= 列表宽度)。 */ + int height = 0; /**< 位图高度(= 行高)。 */ + }; + + std::vector m_rowBitmapCache; /**< 与 `m_rows` 同序的行位图缓存。 */ + + /** + * @brief 行位图缓存的**数量上限**(超过即整体作废重建)。 + * + * 一行位图 ≈ 列表宽 × 行高 × 4B(430×30×4 ≈ 51KB)。用户滚动浏览上千行时, + * 缓存会按"画过的行下标"逐渐长大,必须有上限(这里 192 行 ≈ 10MB)。 + */ + static constexpr std::size_t kRowBitmapCacheLimit = 192; + std::size_t m_rowBitmapLiveCount = 0; /**< 当前持有的行位图数量。 */ + + /** @brief 行绘制正被渲染进离屏位图(此时禁止按列表矩形裁剪)。 */ + bool m_rowClipRender = false; + + /** + * @brief 行位图缓存渲染期间的**几何平移量**(把整行搬到"位图坐标系")。 + * + * ⚠️ 不能用 `gd::Graphics::TranslateTransform`:行内**文本走 GDI `DrawTextW`**, + * 而 GDI 文本**不遵循 GDI+ 的世界变换**(`Graphics::GetHDC` 拿到的 DC 只有裁剪区), + * 于是文字会被画到绝对坐标处、落到小位图之外(实测量产现象:行里只剩复选框/色块, + * 文字全部消失)。因此在**几何层**平移:`RowRect` 是行内一切矩形的唯一来源, + * 它统一减去该原点即可(`SignalBadgeRect` / ✕ / 名称框等全部自动跟随)。 + */ + bool m_rowRenderActive = false; + float m_rowRenderOriginX = 0.0f; /**< 位图左上角(屏幕 x)。 */ + float m_rowRenderOriginY = 0.0f; /**< 位图左上角(屏幕 y)。 */ + /** * @brief 判断哪个分组行需要「悬浮」在列表顶部(返回行下标,无需悬浮时返回 -1)。 * diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 8ad0ec0..7fe62d2 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -374,6 +374,9 @@ namespace jdi { m_rows.clear(); m_rowsDirty = false; + // 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。 + // 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。 + InvalidateRowCache(); if (m_dataSet == nullptr || m_tab == nullptr) { @@ -500,10 +503,15 @@ namespace jdi // 「组名悬浮」期间把分组行钉在列表顶部绘制(见 `PaintStickyGroupRow`): // 本函数是行内所有派生的唯一几何来源,因此只需在这里覆盖顶部坐标, // 名称框 / ✕ / 徽标等位置会自动跟随。 + // + // ⚠️ 行位图缓存渲染时整体平移到"位图坐标系"(见 `m_rowRenderActive` 的说明: + // GDI 文本不认 GDI+ 变换,必须在几何层平移)。 + const float renderOffsetX = m_rowRenderActive ? -m_rowRenderOriginX : 0.0f; + const float renderOffsetY = m_rowRenderActive ? -m_rowRenderOriginY : 0.0f; if (m_rowYOverrideActive) { - return gd::RectF(m_listRect.X, m_rowYOverrideY, m_listRect.Width, - m_rows[static_cast(row)].height); + return gd::RectF(m_listRect.X + renderOffsetX, m_rowYOverrideY + renderOffsetY, + m_listRect.Width, m_rows[static_cast(row)].height); } float top = m_listRect.Y - m_scrollOffset; @@ -516,7 +524,8 @@ namespace jdi { top -= m_rowShift[static_cast(row)]; } - return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(row)].height); + return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width, + m_rows[static_cast(row)].height); } int SignalPanel::GroupLastRowIndex(int row, int rowCount) const diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index 68620af..7d9f9b4 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -4,6 +4,8 @@ // 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。 #include "SignalPanelInternal.hpp" +#include + namespace jdi { @@ -186,14 +188,10 @@ namespace jdi g.SetClip(m_rowClipRect, gd::CombineModeIntersect); } - if (m_rows[static_cast(row)].kind == RowKind::GROUP) - { - PaintGroupRow(g, row); - } - else - { - PaintSignalRow(g, row); - } + // ⚠️ 整行走**位图缓存**:分组折起/展开动画只是在平移行, + // 行的像素一个也没变;逐行重画十来个 GDI+ 原语要 0.35ms/行 + //(一屏 35~40 行 ⇒ 每帧 12~15ms),这是"只有文字的列表却滚动卡顿"的真因。 + PaintRowCached(g, row); g.Restore(rowSave); ClearRowClip(); } @@ -227,6 +225,14 @@ namespace jdi // 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。 // 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内, // 否则垂直居中的文字会溢出到相邻行上。 + // + // ⚠️ **行位图缓存渲染时反而不能裁剪**(`m_rowClipRender`):位图是整行内容, + // 裁剪留到贴图时由 GDI+ 裁剪区完成(否则部分滚出上边界的行会永久丢掉顶部像素)。 + if (m_rowClipRender) + { + UiText(g, text, font, rect, argb, align); + return; + } UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect); } @@ -235,14 +241,251 @@ namespace jdi // 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效 //(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。 gd::RectF clipped = rect; - clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect); - if (clipped.Width <= 0.0f || clipped.Height <= 0.0f) + if (!m_rowClipRender) { - return; + clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect); + if (clipped.Width <= 0.0f || clipped.Height <= 0.0f) + { + return; + } } UiFillRect(g, clipped, argb); } + void SignalPanel::InvalidateRowCache() + { + for (RowBitmapCache& cache : m_rowBitmapCache) + { + if (cache.dc != nullptr) + { + DeleteDC(cache.dc); + cache.dc = nullptr; + } + if (cache.dib != nullptr) + { + DeleteObject(cache.dib); + cache.dib = nullptr; + } + } + m_rowBitmapCache.clear(); + m_rowBitmapLiveCount = 0; + } + + std::uint64_t SignalPanel::RowCacheKey(int row) const + { + 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); + }; + const auto mixText = [&mix](const std::wstring& text) + { + mix(text.size()); + for (const wchar_t character : text) + { + mix(static_cast(character)); + } + }; + + if (row < 0 || row >= static_cast(m_rows.size())) + { + return hash; + } + const ListRow& item = m_rows[static_cast(row)]; + mix(static_cast(item.kind)); + mix(static_cast(item.groupIndex + 1)); + mix(static_cast(item.signalIndex + 1)); + mixFloat(item.height); + mixFloat(m_scale); + mix(static_cast(reinterpret_cast(m_theme))); + mix(static_cast(reinterpret_cast(m_fonts))); + mix(static_cast((m_hoverRow == row) ? (static_cast(m_hoverPart) + 1) : 0)); + + if (item.kind == RowKind::GROUP) + { + const SignalGroup* group = + (m_dataSet != nullptr) ? m_dataSet->GroupAt(item.groupIndex) : nullptr; + mixText((group != nullptr) ? group->name : std::wstring()); + mixText(GroupStatsText(row)); + mix(IsGroupCollapsed(item.groupIndex) ? 1ull : 0ull); + mix(static_cast(item.displayCount + 1)); + if (m_tab != nullptr) + { + mix(m_tab->GroupAllVisible(item.groupIndex) ? 1ull : 0ull); + mix(m_tab->GroupHasVisible(item.groupIndex) ? 2ull : 0ull); + } + const bool editing = (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row); + mix(editing ? 1ull : 0ull); + if (editing) + { + mixText(m_edit.Text()); + } + return hash; + } + + const SignalRef ref{item.groupIndex, item.signalIndex}; + const SignalData* signal = (m_dataSet != nullptr) ? m_dataSet->Resolve(ref) : nullptr; + const SignalStyle* style = StyleOf(ref); + mixText((signal != nullptr) ? signal->name : std::wstring()); + mix((SelectedRef() == ref) ? 1ull : 0ull); + if (style != nullptr) + { + mix(static_cast(style->color)); + mixFloat(style->opacity); + } + if (m_tab != nullptr) + { + mix(m_tab->IsVisible(ref) ? 1ull : 0ull); + mix(static_cast(m_tab->LaneOfSignal(ref) + 1)); + } + const bool editing = (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row); + mix(editing ? 1ull : 0ull); + if (editing) + { + mixText(m_edit.Text()); + } + return hash; + } + + void SignalPanel::PaintRowCached(gd::Graphics& g, int row) + { + if (m_dataSet == nullptr || m_tab == nullptr || m_theme == nullptr || row < 0 || + row >= static_cast(m_rows.size())) + { + return; + } + const gd::RectF rect = RowRect(row); + if (rect.Height <= 0.5f) + { + return; + } + const int originX = static_cast(std::floor(m_listRect.X)); + const int originY = static_cast(std::floor(rect.Y)); + const int width = static_cast(std::ceil(m_listRect.Width)); + const int height = static_cast(std::ceil(rect.Height)); + if (width <= 0 || height <= 0) + { + return; + } + // ⚠️ 上限检查必须在**取引用之前**:`InvalidateRowCache()` 会清空容器, + // 清理后原有引用(`RowBitmapCache&`)就悬空了。 + if (m_rowBitmapLiveCount >= kRowBitmapCacheLimit) + { + InvalidateRowCache(); + } + if (m_rowBitmapCache.size() < m_rows.size()) + { + m_rowBitmapCache.resize(m_rows.size()); + } + RowBitmapCache& cache = m_rowBitmapCache[static_cast(row)]; + const std::uint64_t key = RowCacheKey(row); + const bool sameSize = (cache.dc != nullptr) && cache.width == width && cache.height == height; + if (!sameSize || cache.key != key) + { + if (!sameSize) + { + if (cache.dc != nullptr) + { + DeleteDC(cache.dc); + cache.dc = nullptr; + if (m_rowBitmapLiveCount > 0) + { + --m_rowBitmapLiveCount; + } + } + if (cache.dib != nullptr) + { + DeleteObject(cache.dib); + cache.dib = nullptr; + } + // ⚠️ DIB + 内存 DC(而不是 `gd::Bitmap`):GDI 在 GDI+ 位图的临时 HDC 上 + // 会关闭 ClearType,导致列表文字丢失亚像素渲染、与正文观感不一致。 + 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; + HDC memoryDc = CreateCompatibleDC(nullptr); + HBITMAP dib = (memoryDc != nullptr) + ? CreateDIBSection(memoryDc, &info, DIB_RGB_COLORS, &bits, nullptr, 0) + : nullptr; + if (memoryDc == nullptr || dib == nullptr) + { + if (dib != nullptr) + { + DeleteObject(dib); + } + if (memoryDc != nullptr) + { + DeleteDC(memoryDc); + } + return; + } + SelectObject(memoryDc, dib); + cache.dc = memoryDc; + cache.dib = dib; + cache.width = width; + cache.height = height; + ++m_rowBitmapLiveCount; + } + { + gd::Graphics rowGraphics(cache.dc); + rowGraphics.SetSmoothingMode(gd::SmoothingModeAntiAlias); + rowGraphics.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + rowGraphics.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + rowGraphics.Clear(ToGdiColor(m_theme->panelBg)); + // ⚠️ 用**几何平移**而不是 `TranslateTransform`(见 `m_rowRenderActive`)。 + m_rowRenderActive = true; + m_rowRenderOriginX = static_cast(originX); + m_rowRenderOriginY = static_cast(originY); + m_rowClipRender = true; + if (m_rows[static_cast(row)].kind == RowKind::GROUP) + { + PaintGroupRow(rowGraphics, row); + } + else + { + PaintSignalRow(rowGraphics, row); + } + m_rowClipRender = false; + m_rowRenderActive = false; + } + cache.key = key; + } + // ⚠️ 整块 1:1 搬运(不经过 GDI+ 采样/混合),字的亚像素边缘才能原样保留。 + // ⚠️ **必须自己给 DC 设裁剪**:`Graphics::GetHDC()` 交给我们的 DC 上,(a) GDI 绘制 + // 不受 GDI+ `SetClip` 的约束(这是本文件多处注释过的坑),(b) 折起/展开动画时行会 + // 滑进组名栏,必须裁到 `m_rowClipRect`。因此这里显式 `IntersectClipRect` 后再 `BitBlt`。 + const HDC targetDc = g.GetHDC(); + if (targetDc != nullptr) + { + const int saved = SaveDC(targetDc); + if (saved != 0) + { + const gd::RectF clip = m_rowClipActive ? m_rowClipRect : m_listRect; + IntersectClipRect(targetDc, static_cast(std::floor(clip.X)), + static_cast(std::floor(clip.Y)), + static_cast(std::ceil(clip.X + clip.Width)), + static_cast(std::ceil(clip.Y + clip.Height))); + } + BitBlt(targetDc, originX, originY, width, height, cache.dc, 0, 0, SRCCOPY); + if (saved != 0) + { + RestoreDC(targetDc, saved); + } + g.ReleaseHDC(targetDc); + } + } void SignalPanel::PaintGroupRow(gd::Graphics& g, int row) { if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 13a5aad..7e8e920 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -6112,3 +6112,85 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } 2. **任何"每帧随鼠标变化"的绘制都必须 O(光标附近)**,不能 O(整条曲线);否则一次悬停就能把 60fps 打成 5fps。 3. **性能与正确性判据必须是"同一次运行内的相对量"**:绝对 ms / 绝对墨迹都会随机器状态和视界内容变化而误报。 4. 平移正确性的唯一可信判据是"与同一视界的整幅重绘对比",而不是"与某个固定基准帧对比"。 + +--- + +## 本轮(2026-10-18 第二轮):悬停光晕恢复"整条发光" + 信号列表卡顿真因(行位图缓存) + +用户报告:① 上一轮的光晕修复把高亮变成了"像选区放大的长方形色块"、联动也失效;② 信号选择区 +(**只有文字**的面板)滚动/展开动画依然卡顿,反复"展开→切组复选框→收起→切回"必现。 + +### A. ① 的真因:上一轮把光晕裁到鼠标附近 ±72px + +上一轮为了让"鼠标划过巨型信号"不卡死,把光晕塞进 `kHaloWindowHalfWidth=72` 的窗口。 +但光晕的**语义是"整条曲线一起发光"**:对满幅方波(编码器)来说,一列包络本来就跨满网格高, +裁成一个 144px 宽 × 满高的窗口 ⇒ 屏幕上就是一个**长方形色块**,看起来像选区框。 + +本轮:`PaintSignalHaloFor` 恢复**整条**几何(包络带 `FillPath` / 折线 `DrawLines`), +代价改由 **`PaintSignalHaloCached`** 承担: +- 光晕的几何只取决于「信号 + 视界 + 网格几何 + 样式」,**与鼠标位置无关** ⇒ 渲染一次进离屏位图; +- 每次贴图只做一次 `AlphaBlend`(源位图**预乘 alpha**,见下); +- 键与 `AcquirePolyline` 同源(`PolylineCacheEntry` 的键字段 + 样式字段), + **不含网格屏幕位置**(滚动网格只需按新位置贴同一张位图)。 + +性能:210ms/帧(描边整条)→ **整条包络带填充一次 15ms + 每帧贴图 ~2ms**; +`halocheck` 校验 `outsideBand=0`、`outsideLane=0`、bbox 覆盖整条曲线(不是中段方块)。 + +### B. ② 的真因有三个,都不是"面板没优化" + +用新增探针 `panelstutter`(129 个可见网格 + 反复"展开→切组复选框→收起")量出来的: + +1. **面板每帧重画 ~36 行,每行 ~0.35ms**(十来个 GDI+ 原语)⇒ `panel.Paint` **24~35ms**。 + 一行内容在动画期间**一个像素都没变**,变的只是 y 坐标。 +2. **动画帧里还在预热网格图层**:`WarmLaneLayersStep()` 每帧重建 1 个网格图层, + 单个 2500 万采样信号 **10~30ms** ⇒ 动画帧被硬生生拉长(用户看到的"跳帧")。 +3. **勾选一个分组会把上百个离屏网格排进待重画队列**,随后每帧补 1 个 × 10~30ms ⇒ + **数秒的持续掉帧**(`RollWideBuffer` 对离屏网格也 `QueueLaneRebuild`)。 + +修法: +1. **行位图缓存**(`SignalPanel::PaintRowCached`):按"行的可见内容"做键,把整行渲染进 + 离屏 DIB,命中时一次 `BitBlt` 贴出。`panel.Paint` **24~35ms → 5~6ms**。 + - ⚠️ 位图必须是 **DIB + 内存 DC**,不能用 `gd::Bitmap`:GDI 在 GDI+ 位图的临时 HDC 上 + **会关闭 ClearType**(整列文字退化成灰度抗锯齿、观感变细)。 + - ⚠️ 行内几何必须**在 `RowRect` 里平移**(`m_rowRenderActive`),不能用 + `TranslateTransform`:GDI 文本不认 GDI+ 世界变换 ⇒ 文字会画到小位图之外(实测:只剩复选框与色块)。 + - ⚠️ 渲染到行位图时**不能按列表矩形裁剪**(`m_rowClipRender`),裁剪留到贴图时的 DC 上: + `GetHDC()` 出来的 DC **不继承 GDI+ 裁剪**,必须自己 `SaveDC + IntersectClipRect`(折起动画时 + 行要滑进组名栏、不许画到组名上)。 + - 缓存数量上限 192 行(≈10MB),超过即整体作废。 + - 静态画面**逐像素一致**(面板区域 54/275200 像素不同,最大差 75 —— 滚动条滑块抗锯齿)。 +2. **UI 动画期间不预热网格图层**(`PlotView::SetAnimationBusy`,由 `Application::Render` 传入): + 预热被"推迟"而不是取消(队列保留),动画结束后按既有预算继续。 +3. **待重画队列只保留可视 + 预取带内的网格**(`RenderPendingLaneRebuilds` 丢弃离屏项); + `RollWideBuffer` 也不再登记离屏网格 —— 它们真正进入视区时会按新视界重新登记。 + +实测(`panelstutter`,同一台同一次运行):展开动画 **p50 35ms → 13ms**、收起 **33ms → 13ms**、 +面板绘制 **24~35ms → 5~6ms**、动画帧里"重建网格图层"次数 **56 → 0**。 + +### C. 试过但放弃的两条路(避免以后重走) + +- **`ExtTextOutW` 替代 `DrawTextW`**:约快 2~4 倍,但**光栅化结果不同**(面板区域 10% 像素差异、 + 亚像素边缘差 1px)⇒ 全体界面文字观感变化,放弃。 +- **用 `gd::Bitmap` 做行位图**:文字丢失 ClearType(见 B.1)。 + +### D. 验证(全部离屏,不占用鼠标/键盘/屏幕) + +- 新增 `panelstutter`(8/8):展开/收起动画帧不重建网格图层、动画 p50 ≤ idle+12ms、 + 行悬停仍会重绘该行(行缓存随悬停失效)。 +- 新增 `halocheck`(16/16):悬停必命中(60/60 采样位置)、光晕沿整条曲线且贴合带宽、 + 列表行悬停联动、单信号满高场景耗时受控。 +- 回归:`verify` 49/49、`panelanim` 158/158、`hoverdrag` 20/20、`gridanim` 93/93、 + `lanescroll` 23/23、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、 + `panflash` 19/19、`stress` 6/6。 +- `make release` / `make debug` 零警告(`TARGET_FILE_NAME=TargetCheck/TargetCheckD`)。 + +### E. 教训(新增规则) + +1. **"高亮提示"的语义是"整条曲线发光"**:任何把高亮裁成"鼠标附近一段"的做法,在满幅信号上 + 都会退化成"长方形色块"(用户一眼就看出不对)。性能要用**缓存**解决,不能改变语义。 +2. **GDI 文本的三种"隐形陷阱"**:① 不认 GDI+ 世界变换(要平移几何);② 不认 GDI+ 裁剪区 + (要自己 `IntersectClipRect`);③ 在 GDI+ 位图的临时 HDC 上**关闭 ClearType** + (要改用 DIB + 内存 DC)。 +3. **"面板只有文字却卡顿"多半不是文字本身的锅**:先量"每行成本 × 可见行数", + 再量"这一帧是否顺带在做绘图区的重活"(图层预热 / 队列排空)。 +4. **动画帧里绝不做可延迟的整帧级工作**(图层重建、队列排空);这类工作一律推迟到动画之后。 diff --git a/Makefile b/Makefile index 01d79d3..859e1d1 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,7 @@ WIN_RES_COMPLIER = windres LIB_LINK = \ -lgdi32 \ -lgdiplus \ +-lmsimg32 \ -lcomdlg32 \ -ld3d11 \ -ldwmapi \ diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 2d7454f..d8289b9 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -123,8 +123,64 @@ namespace jdi }; /** 上限保护:超过时整体清空(缓存只是加速手段,清空后重新填充即可)。 */ constexpr std::size_t kMeasureCacheLimit = 4096; - std::unordered_map g_measureCache; + + /** 文本的像素宽度与字符单元高度(后者与 `DT_VCENTER` 的居中口径一致)。 */ + struct TextExtent + { + float width = 0.0f; /**< 像素宽度。 */ + float height = 0.0f; /**< 字符单元高度。 */ + }; + + std::unordered_map g_measureCache; std::mutex g_measureMutex; + + /** + * @brief 取文本的宽 / 高(带缓存)。 + * + * ⚠️ 必须走 GDI(`GetDC` + `GetTextExtentPoint32W` + `ReleaseDC`),单次约 10µs; + * 而布局与绘制每帧会把同一批字符串**重复测量成百上千次**,因此按 (字体, 文本) 缓存。 + * 高度也一并缓存:`DrawUiText` 的快速路径要用它做垂直居中(见那里的说明)。 + */ + TextExtent MeasureText(HFONT font, const std::wstring& text) + { + if (text.empty() || font == nullptr) + { + return TextExtent(); + } + const MeasureKey key{font, text}; + { + std::lock_guard lock(g_measureMutex); + const auto found = g_measureCache.find(key); + if (found != g_measureCache.end()) + { + return found->second; + } + } + + HDC hdc = GetDC(nullptr); + if (hdc == nullptr) + { + return TextExtent(); + } + HGDIOBJ oldFont = SelectObject(hdc, font); + SIZE size = {}; + GetTextExtentPoint32W(hdc, text.c_str(), static_cast(text.size()), &size); + SelectObject(hdc, oldFont); + ReleaseDC(nullptr, hdc); + + TextExtent extent; + extent.width = static_cast(size.cx); + extent.height = static_cast(size.cy); + { + std::lock_guard lock(g_measureMutex); + if (g_measureCache.size() >= kMeasureCacheLimit) + { + g_measureCache.clear(); + } + g_measureCache.emplace(key, extent); + } + return extent; + } } // namespace void UiClearMeasureCache() @@ -413,6 +469,10 @@ namespace jdi rc.right = static_cast(std::ceil(rect.X + rect.Width)); rc.bottom = static_cast(std::ceil(rect.Y + rect.Height)); + // ⚠️ 这里必须继续用 `DrawTextW`:试过改用 `ExtTextOutW` 的"快速路径"(约快 2~4 倍), + // 但同一段文字的**光栅化结果并不相同**(实测面板区域 10% 的像素有差异, + // 亚像素边缘与基线差 1px)—— 全体界面文字观感都会变,因此放弃。 + // 列表卡顿改由"行位图缓存 + 动画期间不预热网格图层"解决(见 `SignalPanel::PaintRowCached`)。 UINT flags = DT_SINGLELINE | DT_VCENTER | DT_NOPREFIX | DT_END_ELLIPSIS; switch (align) { @@ -455,48 +515,9 @@ namespace jdi float UiMeasureTextWidth(HFONT font, const std::wstring& text) { - if (text.empty() || font == nullptr) - { - return 0.0f; - } - - // 记忆化:文本宽度只能走 GDI(GetDC + GetTextExtentPoint32W + ReleaseDC), - // 单次约 10µs;而布局代码每帧会把同一批字符串**重复测量成百上千次**—— - // 例如浮动面板的几何是按「网格数 × 面板种类」反复重算的(5 网格 × 4 类 = 20 次, - // 每次十来个字符串),实测光这一项就占 3ms 以上。字体与文本都相同的测量结果 - // 必然一致,因此按 (字体, 文本) 做一层缓存;字体被重建时由 `UiClearMeasureCache` 作废。 - const MeasureKey key{font, text}; - { - std::lock_guard lock(g_measureMutex); - const auto found = g_measureCache.find(key); - if (found != g_measureCache.end()) - { - return found->second; - } - } - - HDC hdc = GetDC(nullptr); - if (hdc == nullptr) - { - return 0.0f; - } - - HGDIOBJ oldFont = SelectObject(hdc, font); - SIZE size = {}; - GetTextExtentPoint32W(hdc, text.c_str(), static_cast(text.size()), &size); - SelectObject(hdc, oldFont); - ReleaseDC(nullptr, hdc); - - const float width = static_cast(size.cx); - { - std::lock_guard lock(g_measureMutex); - if (g_measureCache.size() >= kMeasureCacheLimit) - { - g_measureCache.clear(); - } - g_measureCache.emplace(key, width); - } - return width; + // 记忆化(键 = 字体 + 文本,值 = 宽 / 高):布局代码每帧会把同一批字符串 + // **重复测量成百上千次**,而 `GetTextExtentPoint32W` 单次约 10µs。 + return MeasureText(font, text).width; } std::wstring UiEllipsize(const std::wstring& text, HFONT font, float maxWidth)