From 7608e85cf0b79a3b187498ab7ad53e0312d0f6ed Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Sat, 10 Oct 2026 21:35:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=98=BE=E7=A4=BA=E6=95=88?= =?UTF-8?q?=E6=9E=9C=E5=B9=B6=E6=B6=88=E9=99=A4=E3=80=8C=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E6=80=A7=E5=8A=9F=E8=83=BD=E3=80=8D=E4=BA=A4=E4=BA=92=E5=8D=A1?= =?UTF-8?q?=E9=A1=BF=EF=BC=9B=E6=B5=AE=E5=8A=A8=E9=9D=A2=E6=9D=BF=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=83=8F=E7=B4=A0=E5=9B=BE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显示效果(回退本轮为提速引入的两处观感变化) - 包络带恢复精确几何(去掉 ±1 列极值扩展:它把梳状细节并成实心块,用户反馈"显示效果没之前好") - 派生曲线光晕恢复折线描边画法(不再用"带外扩填充") 交互流畅度 - 派生曲线只计算「可见区间 ± 保护带」窗口(CurveSpanFor + 区间窗口缓存), 拖动参数滑块不再重算整条曲线;均值滤波的保护带按传播半径 X*N 计算,保证窗口内取值与整条曲线一致 - 超大窗口(中心轴未限定光标区间时的"整条信号"统计,千万级 ≈100MB)改写进一次性缓冲、不进缓存: 消除 1141ms 卡顿尖峰,缓存内存 518MB → 2.6MB - EdgeCenterCountsFor 先查计数缓存再取曲线,且只请求差集所需下标区间(该函数面板每帧都调用) - 浮动面板改为像素图层(PanelGlobalKey / PanelSlotKey + BitBlt 贴图):只重画变脏的面板, 其余直接贴图;重叠面板用 dirtyRects 级联保证不会把旧像素盖回去 - 快速帧(交互期间)统一关闭抗锯齿(几何逐点不变),松手后强制补一帧全质量画面 其他 - 弱波增强面板内「迭代次数 N」与「弱波增强等级」两个滑块标签左对齐到同一列 验证 - .preflight/uitest 新增 [AF]:贴图帧与重画帧绘图区逐像素一致(0 差异)、面板像素一致、 连续重画确定性、PaintEdgeCursor 62~70ms → 0.3ms;全套 374 项检查通过 - .preflight/perf4 实测(VERY_BIG_DATA,5 网格全开实验性功能): 弱波增强滑块拖拽中位 58.2 → 23.6ms;平移交互帧 61 → 23ms;最大卡顿尖峰 1141 → 47ms --- Components/PlotView/Inc/PlotView.hpp | 218 ++++- Components/PlotView/Src/PlotView.cpp | 1236 +++++++++++++++++++------- 2 files changed, 1109 insertions(+), 345 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index dea21b1..93f2b3d 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -91,6 +91,17 @@ namespace jdi BOOST = 3 /**< 弱波增强。 */ }; + /** + * @brief 派生(叠加预览)曲线的种类。 + * + * 「中心轴边沿计数」与「均值滤波」共用 `MEAN_FILTER`(同一套参数与同一条曲线)。 + */ + enum class DerivedCurveKind : int + { + MEAN_FILTER = 0, /**< 多次迭代均值滤波。 */ + WEAK_BOOST = 1 /**< 弱波增强。 */ + }; + /** * @brief 波形绘图区:网格、时间轴、光标、缩放、自适应与信号拖拽。 * @@ -1030,7 +1041,7 @@ namespace jdi void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks, std::vector& band, std::vector& bandBreaks, - bool& bandMode) const; + bool& bandMode, bool& bandSpiky) const; /** * @brief 绘制单个网格。 @@ -1064,6 +1075,23 @@ namespace jdi */ void PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes); + /** + * @brief 确保面板图层缓存位图可用(尺寸不符时重建)。 + * @return 可用返回 true。 + */ + bool EnsurePanelCache(); + + /** @brief 释放面板图层缓存位图。 */ + void ReleasePanelCache(); + + /** + * @brief 计算「全局」面板缓存键。 + * + * 它涵盖所有面板共同依赖的状态(几何、缩放、主题、光标、选区、悬停、统计区间等); + * 一旦变化,所有面板缓存一律作废(整层重画)。 + */ + std::uint64_t PanelGlobalKey() const; + /** * @brief 判断坐标是否命中某网格的「边沿计数」横向光标线。 * @param [IN] x 客户区横坐标。 @@ -1109,6 +1137,23 @@ namespace jdi */ EdgePanelLayout EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const; + /** + * @brief 计算单个面板的缓存键(全局键之外,面板自身的内容)。 + * @param [IN] laneIndex 面板所属网格。 + * @param [IN] kind 面板种类。 + * @param [IN] layout 面板布局。 + * @return 缓存键。 + */ + std::uint64_t PanelSlotKey(int laneIndex, EdgePanelKind kind, + const EdgePanelLayout& layout) const; + + /** + * @brief 判断某个矩形是否完全落在面板图层位图之内。 + * @param [IN] rect 目标矩形(客户区坐标)。 + * @return 完全落在图层内返回 true。 + */ + bool PanelRectInsideLayer(const gd::RectF& rect) const; + /** * @brief 获取多阶(迭代)均值滤波曲线,结果带缓存。 * @@ -1192,10 +1237,49 @@ namespace jdi * @param [IN,OUT] g 绘图上下文。 * @param [IN] resolved 信号视图。 * @param [IN] lane 网格绘制信息。 - * @param [IN] curve 曲线数值数组(与信号采样等长,缺失处为 NaN)。 + * @param [IN] curve 曲线数值数组(缺失处为 NaN)。 + * @param [IN] curveBegin `curve[0]` 对应的采样下标。 */ void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& curve) const; + const std::vector& curve, std::size_t curveBegin) const; + + /** + * @brief 取得某个采样区间内的派生曲线(带缓存)。 + * + * ⚠️ **性能关键**:派生曲线只用于“看得见的那一段”,而整条信号可能有数千万个采样点 + * (VERY_BIG_DATA 为 2576 万)。若每次都重算整条曲线(实测弱波增强 ~800ms/条), + * 拖动等级/迭代次数滑块时每帧要花几秒钟,交互完全卡死。 + * 本函数只计算“所需区间 ± 保护带”,并按(信号 + 参数 + 对齐后的区间)缓存: + * 实测单条降到 1~3ms,滑块拖动与平移因此恢复流畅。 + * + * 保护带保证可见区间内的取值与“整条曲线”一致:均值滤波的核是有限宽度, + * 弱波增强的修正沿相邻极值向内传播(每迭代一次传一个极值), + * 因此只要求保护带足够宽(见 `kDerivedCurveGuard` 系列常量)。 + * + * @param [IN] kind 曲线种类。 + * @param [IN] signal 目标信号。 + * @param [IN] points 均值点数 X(仅 `MEAN_FILTER` 使用)。 + * @param [IN] iterations 迭代次数 N。 + * @param [IN] levelPercent 弱波增强等级(仅 `WEAK_BOOST` 使用,0–100)。 + * @param [IN] begin 需要的采样区间起点(含)。 + * @param [IN] end 需要的采样区间终点(不含)。 + * @param [OUT] spanBegin 返回数组对应的首个采样下标。 + * @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。 + */ + const std::vector* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, + int points, int iterations, int levelPercent, + std::size_t begin, std::size_t end, + std::size_t& spanBegin) const; + + /** + * @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。 + * @param [IN] file 目标文件。 + * @param [IN] count 可用采样点数。 + * @param [OUT] begin 区间起点(含)。 + * @param [OUT] end 区间终点(不含)。 + */ + void VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin, + std::size_t& end) const; /** * @brief 绘制某条信号的多次迭代均值滤波曲线(与中心平均值曲线同一配色派生规则)。 @@ -1438,6 +1522,14 @@ namespace jdi std::vector m_pendingWeakBoostItems; /**< 待构建的弱波增强信号及其参数。 */ int m_boostDragLane = -1; /**< 正在拖动滑块的面板所属网格。 */ bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ + /** + * @brief 本帧是否使用「快速绘制」(交互期间关闭波形与派生曲线的抗锯齿)。 + * + * 用户明确要求"先用低分辨率,绘制完成后再用高分辨率替换":抗锯齿是单帧最贵的 + * 一项(同一条包络带实测 27.6ms → 3.2ms),而它的几何完全不变, + * 因此拖拽/拖动滑块期间关闭它,松手后由宿主整帧失效自动恢复全质量绘制。 + */ + bool m_fastFrame = false; /** * @brief 实验性功能浮动面板的 ✕ 处于"已按下、等待抬起确认"状态。 @@ -1503,6 +1595,37 @@ namespace jdi }; mutable std::deque m_weakBoostCurveCache; + /** + * @brief 派生曲线的「区间窗口」缓存项。 + * + * `values[i]` 对应采样下标 `begin + i`;`begin/end` 是**实际计算**的区间 + * (已向外扩了保护带),命中条件是「参数一致且窗口包含所需区间」。 + */ + struct CurveSpanEntry + { + const SignalData* signal = nullptr; /**< 目标信号。 */ + int kind = 0; /**< `DerivedCurveKind`。 */ + int points = 5; /**< 均值点数 X。 */ + int iterations = 1; /**< 迭代次数 N。 */ + int levelPercent = 0; /**< 弱波增强等级(0–100)。 */ + std::size_t begin = 0; /**< 已计算区间的起点(采样下标)。 */ + std::size_t end = 0; /**< 已计算区间的终点(不含)。 */ + std::vector values; /**< 曲线数值。 */ + }; + mutable std::deque m_curveSpanCache; /**< 区间窗口缓存(deque 保证引用稳定)。 */ + + /** + * @brief 「超大窗口」派生曲线的一次性缓冲。 + * + * 中心轴边沿计数在**没有限定光标区间**时要统计整条信号,对应窗口与信号等长 + *(亿级采样 ≈ 100MB/信号)。这类窗口绝不进 `m_curveSpanCache`: + * 一旦被淘汰,下一帧就会重算整条曲线(实测单次 ≈ 1s 的卡顿尖峰), + * 而且几个信号同时驻留会吃掉数百 MB。 + * 改为写入本缓冲:调用方(`EdgeCenterCountsFor`)在其返回后立即用完, + * 因此生命周期安全。⚠️ 指针仅在下一次「超大窗口」请求之前有效。 + */ + mutable std::vector m_curveSpanScratch; + /** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */ struct EdgeCenterCountEntry { @@ -1569,6 +1692,17 @@ namespace jdi */ bool bandMode = false; + /** + * @brief 该包络带是否为「梳状」(相邻列的上下包络几乎不相交)。 + * + * 振荡周期与像素列宽相当时(例如编码器方波在 2ms/大格下约 3 个采样点一列), + * 逐列包络会在"全高"与"极窄"之间交替。这种形状下 GDI+ 的抗锯齿扫描线代价暴涨 + * (实测同一条 2448 顶点的路径:27.6ms vs 无抗锯齿 3.2ms), + * 而抗锯齿对它的平滑作用几乎看不出来 —— 因此绘制时改用无抗锯齿填充。 + * ⚠️ 几何本身仍然是**精确**的(不做任何加宽),否则波形会被"糊"成实心块。 + */ + bool bandSpiky = false; + /** 包络带顶点:每段为「上包络正向 + 下包络反向」,闭合后即为该段的填充多边形。 */ std::vector band; @@ -1661,12 +1795,39 @@ namespace jdi int mode = 0; /**< 绘制模式。 */ }; + /** + * @brief 单个网格内「叠加预览曲线」的状态(开关 + 每条被勾选信号的参数)。 + * + * 它**只影响网格内容的绘制**(不影响网格结构/视图几何),因此不进入 + * `SceneGeometryHash`,而是与外观样式一起作为“局部重画”的判据: + * 拖动弱波增强的等级/迭代次数滑块时,只需重画受影响的网格, + * 不必整幅重绘(也不会把静态场景缓存整体作废)。 + */ + struct SceneDerivedEntry + { + int edgeCenter = 0; /**< 中心轴边沿计数开关。 */ + int meanFilter = 0; /**< 均值滤波开关。 */ + int weakBoost = 0; /**< 弱波增强开关。 */ + int weakBoostLevelPercent = 0; /**< 弱波增强等级(0–100)。 */ + int weakBoostIterations = 1; /**< 弱波增强迭代次数 N。 */ + /** 各条目展开为 [文件, 信号, 均值点数 X, 迭代次数 N],按原顺序(稳定)。 */ + std::vector edgeCenterItems; + std::vector meanFilterItems; + std::vector weakBoostItems; + }; + /** * @brief 采集当前各网格内可见信号的外观样式快照。 * @param [OUT] out 采集结果(按网格分组)。 */ void CollectSceneStyles(std::vector>& out) const; + /** + * @brief 采集当前各网格的「叠加预览曲线」状态快照。 + * @param [OUT] out 采集结果(按网格分组)。 + */ + void CollectSceneDerived(std::vector& out) const; + /** * @brief 判断当前相对缓存是否"只有外观样式发生了变化"。 * @@ -1731,20 +1892,6 @@ namespace jdi void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const std::vector& points, const std::vector& breaks); - /** - * @brief 稠密包络带的光晕:把带整体外扩 `extra` 像素后填充一次。 - * - * 比用宽画笔描整条折线快一个数量级(含大量近垂直跳变的信号上尤其明显), - * 观感也更均匀。 - * - * @param [IN,OUT] g 绘图上下文。 - * @param [IN] band 包络带顶点(上包络正向 + 下包络反向)。 - * @param [IN] bandBreaks 各段起点。 - * @param [IN] extra 纵向额外外扩量(像素)。 - */ - void PaintBandHalo(gd::Graphics& g, const std::vector& band, - const std::vector& bandBreaks, float extra); - /** * @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。 * @param [IN] laneIndex 网格下标。 @@ -1837,6 +1984,43 @@ namespace jdi /** 缓存捕获时各网格内信号的外观样式快照(按网格分组,与 m_sceneStyles 同序)。 */ std::vector> m_sceneStyles; + /** 缓存捕获时的“叠加预览曲线”状态快照(用于判断只有曲线参数变化)。 */ + std::vector m_sceneDerived; + + /** + * @brief 静态场景缓存里存的是否为「快速帧」(交互期间关掉了抗锯齿)。 + * + * 为 true 时 `SceneCacheMatches()` 直接判不命中,强制重画一次全质量版本 —— + * 这就是用户要求的“先用低分辨率、绘制完成后再用高分辨率替换”。 + */ + bool m_sceneDegraded = false; + + /** + * @brief 实验性功能浮动面板的像素图层项。 + * + * 面板被完整绘制进 `m_panelDc`;只要 `key` 不变,下一帧就直接按 + * `[x0, y0, x1, y1)` 贴图,不再重复绘制(15 个面板全画约 33ms,贴图 <1ms)。 + */ + struct PanelSlot + { + int laneIndex = -1; /**< 所属网格。 */ + int kind = 0; /**< `EdgePanelKind`。 */ + std::uint64_t key = 0; /**< 面板自身的缓存键。 */ + int x0 = 0; /**< 已缓存矩形(含阴影外扩),左。 */ + int y0 = 0; /**< 上。 */ + int x1 = 0; /**< 右(不含)。 */ + int y1 = 0; /**< 下(不含)。 */ + }; + + HBITMAP m_panelBitmap = nullptr; /**< 面板图层位图(原点与客户区一致)。 */ + HDC m_panelDc = nullptr; /**< 面板图层设备上下文。 */ + HGDIOBJ m_panelOldBitmap = nullptr; /**< 面板图层 DC 原选位图。 */ + int m_panelWidth = 0; /**< 面板图层位图宽度。 */ + int m_panelHeight = 0; /**< 面板图层位图高度。 */ + bool m_panelValid = false; /**< 图层内容是否有效。 */ + std::uint64_t m_panelGlobalKey = 0; /**< 上一层对应的全局键。 */ + std::vector m_panelSlots; /**< 上一层缓存的面板(本帧比对用)。 */ + /** * @brief 查询坐标命中的图例条目。 * @param [IN] x 客户区横坐标。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 9c573c3..9972887 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -75,6 +75,23 @@ namespace jdi const float kWeakBoostTrackMinWidth = 120.0f; /**< 弱波增强滑块轨道的最小宽度。 */ const int kWeakBoostIterationsMin = 1; /**< 迭代次数 N 的下限。 */ const int kWeakBoostIterationsMax = 10; /**< 迭代次数 N 的上限。 */ + /** + * @brief 派生曲线「区间窗口」缓存的保护带上限(采样点数)。 + * + * 窗口只覆盖可见区间 ± 保护带,可见区间内的取值与整条曲线一致(见 `CurveSpanFor`)。 + * 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。 + */ + const std::size_t kDerivedCurveGuardMax = 65536; + /** 区间窗口缓存条目上限(超出时整体清空;它只是加速手段)。 */ + const std::size_t kCurveSpanCacheLimit = 32; + /** + * @brief 进入「区间窗口缓存」的单条窗口采样数上限。 + * + * 超过它的窗口(例如「中心轴边沿计数」在未限定光标区间时的整条信号) + * 改写进 `m_curveSpanScratch`:缓存的窗口一旦被淘汰就得重算整条曲线 + * (亿级采样实测 ≈1s 的卡顿尖峰),而且几条曲线同时驻留就会吃掉数百 MB。 + */ + const std::size_t kCurveSpanCacheMaxSamples = 4194304; const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */ @@ -278,26 +295,23 @@ namespace jdi }; /** - * @brief 把逐列极值拼成闭合的包络带多边形,并做 ±1 列的极值扩展。 + * @brief 把逐列极值拼成闭合的包络带多边形。 * * 输出格式为「上包络正向 + 下包络反向」,调用方(`PaintLane`)依赖这个顺序把 * 它拆成上下两条折线画成一个闭合环。 * - * **±1 列的极值扩展是性能关键,不是美观偏好**: - * 当信号的振荡周期与像素列宽度相当时(例如 VERY_BIG_DATA 的编码器方波在 - * 2ms/大格下约 3 个采样点一列),同一列可能只落在高电平或只落在低电平, - * 于是逐列包络会在"全高"与"极窄"之间交替,形成大量 1px 宽的全高尖刺 - * (实测 1224 列里有 988 处全高跳变)。这种"梳状"多边形会让 GDI+ 的 - * 扫描线填充耗时暴涨(实测同一条 2448 顶点的路径:27.6ms → 1.1ms,快 25 倍)。 - * 扩展窗口只让包络在**纵向上最多各多出 1 像素**,肉眼看不出差别, - * 但它把尖刺合并成连续块,使填充耗时回到正常水平。 + * ⚠️ **几何必须是精确的**:这里**不做任何**列窗口加宽。曾经为了让填充更快而做过 + * ±1 列的极值扩展,结果把"梳状"振荡糊成了实心块,波形细节整体丢失 + * (用户反馈"显示效果没有之前好了")。性能问题改由**绘制侧**解决:把梳状带 + * 交给无抗锯齿填充(见 `spiky` 输出与 `PlotView::m_fastFrame`)。 * * @param [IN] columns 逐列极值(同一段内按列顺序排列)。 * @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。 * @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。 + * @param [OUT] spiky 该段是否为「梳状」(相邻列包络几乎不相交)。 */ void EmitEnvelopeBand(const std::vector& columns, std::vector& band, - std::vector& bandBreaks) + std::vector& bandBreaks, bool& spiky) { if (columns.size() < 2) { @@ -306,41 +320,30 @@ namespace jdi const std::size_t count = columns.size(); bandBreaks.push_back(band.size()); - // 上包络:窗口内取**较小**的纵坐标(更靠上);下包络:取较大的纵坐标。 - const auto widenedTop = [&columns, count](std::size_t index) + // 「梳状」判定:相邻列的上下包络几乎不相交(振荡周期与像素列宽相当时出现)。 + // 这种形状下抗锯齿填充的扫描线代价暴涨,而抗锯齿对它的平滑作用几乎看不出来。 + std::size_t inverted = 0; + const float slack = kEnvelopeMinPixelSpan; + for (std::size_t index = 1; index < count; ++index) { - float value = columns[index].top; - if (index > 0) + if (columns[index].top > columns[index - 1].bottom + slack || + columns[index].bottom < columns[index - 1].top - slack) { - value = std::min(value, columns[index - 1].top); + ++inverted; } - if (index + 1 < count) - { - value = std::min(value, columns[index + 1].top); - } - return value; - }; - const auto widenedBottom = [&columns, count](std::size_t index) + } + if (inverted * 8 > count) { - float value = columns[index].bottom; - if (index > 0) - { - value = std::max(value, columns[index - 1].bottom); - } - if (index + 1 < count) - { - value = std::max(value, columns[index + 1].bottom); - } - return value; - }; + spiky = true; + } for (std::size_t index = 0; index < count; ++index) { - band.push_back(gd::PointF(columns[index].x, widenedTop(index))); + band.push_back(gd::PointF(columns[index].x, columns[index].top)); } for (std::size_t index = count; index-- > 0;) { - band.push_back(gd::PointF(columns[index].x, widenedBottom(index))); + band.push_back(gd::PointF(columns[index].x, columns[index].bottom)); } } @@ -624,6 +627,7 @@ namespace jdi PlotView::~PlotView() { ReleaseSceneCache(); + ReleasePanelCache(); } void PlotView::Setup(const UiFonts* fonts, const Theme* theme) @@ -1240,6 +1244,7 @@ namespace jdi m_edgeCenterCountCache.clear(); m_meanCurveCache.clear(); m_weakBoostCurveCache.clear(); + m_curveSpanCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 m_hoverSignal = SignalRef(); m_hoverLegend = SignalRef(); @@ -1862,9 +1867,10 @@ namespace jdi return false; } lane.weakBoostIterations = clamped; - // 迭代次数变化会改变预览曲线:清掉曲线缓存并让场景缓存失效。 + // 迭代次数变化会改变预览曲线:清掉整条曲线的缓存即可。 + // ⚠️ **不要**再 `InvalidateSceneCache()`:那会整幅重绘(5 个网格、上万个顶点), + // 拖滑块时每帧都发生;改为依赖 `m_sceneDerived` 快照 → 只重画受影响的网格。 m_weakBoostCurveCache.clear(); - InvalidateSceneCache(); return true; } @@ -1881,9 +1887,9 @@ namespace jdi return false; } lane.weakBoostLevel = clamped; - // 等级变化会改变预览曲线:清掉曲线缓存并让场景缓存失效。 + // 等级变化会改变预览曲线:清掉整条曲线的缓存即可(同 `SetLaneWeakBoostIterations`, + // 故意**不**作废静态场景缓存,以便走"只重画受影响网格"的快路径)。 m_weakBoostCurveCache.clear(); - InvalidateSceneCache(); return true; } @@ -2071,6 +2077,141 @@ namespace jdi return true; } + void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin, + std::size_t& end) const + { + begin = 0; + end = count; + if (!file.timeMonotonic || count == 0) + { + return; + } + const std::vector& time = file.time; + begin = static_cast( + std::lower_bound(time.begin(), time.end(), Tab().ViewMin()) - time.begin()); + if (begin > 0) + { + --begin; + } + end = static_cast( + std::lower_bound(time.begin(), time.end(), Tab().ViewMax()) - time.begin()); + if (end < count) + { + ++end; + } + end = std::max(end, begin); + begin = std::min(begin, count); + end = std::min(end, count); + } + + const std::vector* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, + int points, int iterations, int levelPercent, + std::size_t begin, std::size_t end, + std::size_t& spanBegin) const + { + spanBegin = 0; + const std::size_t count = signal.values.size(); + if (count == 0) + { + return nullptr; + } + begin = std::min(begin, count); + end = std::min(std::max(end, begin), count); + if (end <= begin) + { + return nullptr; + } + + const int kindValue = static_cast(kind); + const int safePoints = std::max(0, std::min(points, 1000000)); + const int safeIterations = std::max(1, std::min(iterations, 1000)); + const int safeLevel = std::max(0, std::min(100, levelPercent)); + + // 对齐到粗网格:轻微平移/缩放时可直接复用同一条窗口,避免每帧重算。 + const std::size_t grid = std::max(4096, (end - begin) / 4); + const std::size_t alignedBegin = (begin / grid) * grid; + const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid); + + for (const CurveSpanEntry& entry : m_curveSpanCache) + { + if (entry.signal == &signal && entry.kind == kindValue && entry.points == safePoints && + entry.iterations == safeIterations && entry.levelPercent == safeLevel && + entry.begin <= begin && entry.end >= end) + { + spanBegin = entry.begin; + return &entry.values; + } + } + + // 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。 + // 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播 + // (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。 + // + // 均值滤波的保护带必须**覆盖滤波半径**:第 k 阶的输出在 i 处依赖第 k-1 阶的 + // [i-X, i+X] 取样,N 阶总共向外传播 X*N 个采样。否则窗口边界处的取值会与 + // 「整条曲线」不同(边界上的可用采样数更少)。 + std::size_t guard = std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), + kDerivedCurveGuardMax); + if (kind == DerivedCurveKind::MEAN_FILTER) + { + const std::size_t reach = static_cast(safePoints + 1) * + static_cast(safeIterations) + 64u; + guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax); + } + const std::size_t computedBegin = (alignedBegin > guard) ? (alignedBegin - guard) : 0; + const std::size_t computedEnd = std::min(count, alignedEnd + guard); + + // 只把这个窗口拷进自由函数(它们按整段向量工作)。 + const std::vector& values = signal.values; + const std::vector window(values.begin() + static_cast(computedBegin), + values.begin() + static_cast(computedEnd)); + + // ⚠️ 超大窗口(例如「中心轴边沿计数」的整条信号统计)不进缓存: + // 进缓存迟早会被淘汰,淘汰后下一帧就要重算整条曲线 —— 实测单次 ≈1s 的卡顿尖峰, + // 而且多个信号同时驻留会占掉数百 MB。改为写入一次性缓冲。 + if ((computedEnd - computedBegin) > kCurveSpanCacheMaxSamples) + { + m_curveSpanScratch.clear(); + if (kind == DerivedCurveKind::WEAK_BOOST) + { + ComputeWeakWaveBoost(window, static_cast(safeLevel) / 100.0, safeIterations, + m_curveSpanScratch); + } + else + { + ComputeMovingAverageIterated(window, safePoints, safeIterations, m_curveSpanScratch); + } + spanBegin = computedBegin; + return &m_curveSpanScratch; + } + + if (m_curveSpanCache.size() >= kCurveSpanCacheLimit) + { + m_curveSpanCache.clear(); + } + m_curveSpanCache.push_back(CurveSpanEntry()); + CurveSpanEntry& entry = m_curveSpanCache.back(); + entry.signal = &signal; + entry.kind = kindValue; + entry.points = safePoints; + entry.iterations = safeIterations; + entry.levelPercent = safeLevel; + entry.begin = computedBegin; + entry.end = computedEnd; + + if (kind == DerivedCurveKind::WEAK_BOOST) + { + ComputeWeakWaveBoost(window, static_cast(safeLevel) / 100.0, safeIterations, + entry.values); + } + else + { + ComputeMovingAverageIterated(window, safePoints, safeIterations, entry.values); + } + spanBegin = entry.begin; + return &m_curveSpanCache.back().values; + } + const std::vector& PlotView::MeanFilterCurveFor(const SignalData& signal, int points, int iterations) const { @@ -2129,12 +2270,16 @@ namespace jdi { return false; } - const std::vector& curve = WeakBoostCurveFor(signal, levelPercent, iterations); - if (static_cast(index) >= curve.size()) + std::size_t spanBegin = 0; + const std::vector* curve = CurveSpanFor( + DerivedCurveKind::WEAK_BOOST, signal, 0, iterations, levelPercent, + static_cast(index), static_cast(index) + 1, spanBegin); + if (curve == nullptr || static_cast(index) < spanBegin || + (static_cast(index) - spanBegin) >= curve->size()) { return false; } - const float sampled = curve[static_cast(index)]; + const float sampled = (*curve)[static_cast(index) - spanBegin]; if (!std::isfinite(sampled)) { return false; @@ -2152,38 +2297,11 @@ namespace jdi const int radius = std::max(0, points); const int stages = std::max(1, iterations); - // 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存)。 - const std::vector& curve = MeanFilterCurveFor(signal, radius, stages); - const std::size_t count = std::min(curve.size(), signal.values.size()); + const std::size_t count = signal.values.size(); std::size_t begin = 0; std::size_t end = 0; const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); - // 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。 - const auto deltaAt = [&signal, &file, &curve, count, hasRange, rangeMin, - rangeMax](std::size_t index) -> double - { - if (index >= count) - { - return std::numeric_limits::quiet_NaN(); - } - const double value = signal.values[index]; - const float center = curve[index]; - if (!std::isfinite(value) || !std::isfinite(center)) - { - return std::numeric_limits::quiet_NaN(); - } - if (hasRange && !file.timeMonotonic && index < file.time.size()) - { - const double time = file.time[index]; - if (time < rangeMin || time > rangeMax) - { - return std::numeric_limits::quiet_NaN(); - } - } - return value - static_cast(center); - }; - // 按「文件 + 信号 + 均值点数 X + 迭代次数 N + 是否限定区间」定位缓存项 //(**时间区间不进缓存键**)。 EdgeCenterCountEntry* entry = nullptr; @@ -2213,7 +2331,9 @@ namespace jdi entry = &m_edgeCenterCountCache.back(); } - // 区间完全一致 → 直接命中。 + // 区间完全一致 → 直接命中。**必须放在请求曲线之前**:面板每帧都会调用本函数, + // 而「没有限定光标区间」时的统计区间就是整条信号 —— 若先请求曲线, + // 每一帧都会去取(并可能重算)整条曲线,实测单帧 30ms 以上。 if (indexed && entry->reusable && entry->begin == begin && entry->end == end) { rising = entry->rising; @@ -2227,29 +2347,103 @@ namespace jdi const std::size_t oldBegin = entry->begin; const std::size_t oldEnd = entry->end; - std::size_t deltaRising = 0; - std::size_t deltaFalling = 0; + // 先确定需要比较的「相邻采样对」下标区间 [pairFirst, pairLast](比较 index-1 与 index), + // 再据此只请求**必需的那一小段**曲线窗口(增量路径下通常只有几十个采样)。 + std::size_t pairFirst = 0; + std::size_t pairLast = 0; + int mode = 0; // 0 = 全量重算,1 = 仅尾部差集,2 = 仅头部差集 if (shareBegin && !shareEnd) { const std::size_t low = std::min(oldEnd, end); const std::size_t high = std::max(oldEnd, end); - AccumulateEdgeCrossings(std::max(1, low), high - 1, deltaAt, deltaRising, - deltaFalling); - rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); - falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); + pairFirst = std::max(1, low); + pairLast = (high > 0) ? (high - 1) : 0; + mode = 1; } else if (shareEnd && !shareBegin) { const std::size_t low = std::min(oldBegin, begin); const std::size_t high = std::max(oldBegin, begin); - AccumulateEdgeCrossings(low + 1, high, deltaAt, deltaRising, deltaFalling); + pairFirst = low + 1; + pairLast = high; + mode = 2; + } + else + { + pairFirst = (end > begin) ? (begin + 1) : 1; + pairLast = (end > begin) ? (end - 1) : 0; + mode = 0; + } + + // 需要的偏差下标区间 = [pairFirst - 1, pairLast];请求曲线窗口需包含它。 + const std::size_t needFirst = (pairFirst > 0) ? (pairFirst - 1) : 0; + const std::size_t needEnd = std::min(count, pairLast + 1); + if (needEnd <= needFirst) + { + entry->reusable = indexed; + entry->begin = begin; + entry->end = end; + entry->rising = 0; + entry->falling = 0; + return; + } + + // 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存), + // 但**只取必需区间 ± 保护带**的窗口。 + std::size_t spanBegin = 0; + const std::vector* curvePtr = CurveSpanFor( + DerivedCurveKind::MEAN_FILTER, signal, radius, stages, 0, needFirst, needEnd, spanBegin); + if (curvePtr == nullptr) + { + return; + } + const std::vector& curve = *curvePtr; + + // 判定基准:信号与中心平均值曲线之差(区间之外的采样返回 NaN)。 + const auto deltaAt = [&signal, &file, &curve, spanBegin, count, hasRange, rangeMin, + rangeMax](std::size_t index) -> double + { + if (index >= count) + { + return std::numeric_limits::quiet_NaN(); + } + if (index < spanBegin || (index - spanBegin) >= curve.size()) + { + return std::numeric_limits::quiet_NaN(); + } + const double value = signal.values[index]; + const float center = curve[index - spanBegin]; + if (!std::isfinite(value) || !std::isfinite(center)) + { + return std::numeric_limits::quiet_NaN(); + } + if (hasRange && !file.timeMonotonic && index < file.time.size()) + { + const double time = file.time[index]; + if (time < rangeMin || time > rangeMax) + { + return std::numeric_limits::quiet_NaN(); + } + } + return value - static_cast(center); + }; + + std::size_t deltaRising = 0; + std::size_t deltaFalling = 0; + if (mode == 1) + { + AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling); + rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); + falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); + } + else if (mode == 2) + { + AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling); rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); } else { - const std::size_t pairFirst = (end > begin) ? (begin + 1) : 1; - const std::size_t pairLast = (end > begin) ? (end - 1) : 0; AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, rising, falling); } @@ -3194,10 +3388,9 @@ namespace jdi { item.iterations = value; } - // 参数变化会改变曲线:清掉派生缓存后重绘。 + // 参数变化会改变曲线:清掉派生缓存即可(局部重画由 `m_sceneDerived` 快照驱动)。 m_meanCurveCache.clear(); m_edgeCenterCountCache.clear(); - InvalidateSceneCache(); return true; } @@ -4121,7 +4314,8 @@ namespace jdi m_meanCurveCache.clear(); m_edgeCenterCountCache.clear(); m_weakBoostCurveCache.clear(); - InvalidateSceneCache(); + // 勾选变化只影响该网格的内容:交给 `m_sceneDerived` 快照驱动局部重画, + // 这里**不**整体作废场景缓存(否则每次勾选都要重绘全部网格)。 m_dragMode = PlotDragMode::NONE; return true; } @@ -4635,7 +4829,7 @@ namespace jdi entry.laneYMax = lane.yMax; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, - entry.bandMode); + entry.bandMode, entry.bandSpiky); // BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。 return &m_polylineCache.back(); } @@ -4643,13 +4837,14 @@ namespace jdi void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks, std::vector& band, std::vector& bandBreaks, - bool& bandMode) const + bool& bandMode, bool& bandSpiky) const { points.clear(); breaks.clear(); band.clear(); bandBreaks.clear(); bandMode = false; + bandSpiky = false; if (resolved.data == nullptr || resolved.file == nullptr) { @@ -4736,9 +4931,11 @@ namespace jdi const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f; std::vector segmentColumns; - const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns]() + const auto flushBandSegment = [&band, &bandBreaks, &segmentColumns, &bandSpiky]() { - EmitEnvelopeBand(segmentColumns, band, bandBreaks); + bool spiky = false; + EmitEnvelopeBand(segmentColumns, band, bandBreaks, spiky); + bandSpiky = bandSpiky || spiky; segmentColumns.clear(); }; @@ -4986,6 +5183,12 @@ namespace jdi return; } + // 交互期间(拖拽 / 拖动滑块)改用「快速绘制」:关闭波形与派生曲线的抗锯齿。 + // 用户明确要求"先用低分辨率,绘制完成后再用高分辨率替换"—— + // 抗锯齿是单帧最贵的一项(同一条包络带实测 27.6ms → 3.2ms),几何完全不变; + // 松手时宿主会整帧失效,因此全质量版本随即补上。 + m_fastFrame = (m_dragMode != PlotDragMode::NONE); + // 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。 // 三个分支(完全命中 / 局部重画 / 整幅重绘)共用同一段,保证观感完全一致。 const auto paintOverlays = [this, &g]() @@ -5079,7 +5282,11 @@ namespace jdi // 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容, // 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。 + // ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走 + // "只重画受影响网格"的快路径),但打上 `m_sceneDegraded` 标记 → + // `SceneCacheMatches()` 会拒绝命中,交互结束后的下一帧必定重画全质量版本。 CaptureSceneCache(g); + m_sceneDegraded = m_fastFrame; paintOverlays(); } @@ -5091,12 +5298,8 @@ namespace jdi { return; } - if (cached.bandMode && !cached.bandBreaks.empty()) - { - // 稠密包络带:直接用带的外扩填充(比宽笔描折线快一个数量级)。 - PaintBandHalo(g, cached.band, cached.bandBreaks, 2.0f * m_scale); - return; - } + // 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充", + // 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。 PaintSignalHalo(g, resolved, cached.points, cached.breaks); } @@ -5128,53 +5331,6 @@ namespace jdi } } - void PlotView::PaintBandHalo(gd::Graphics& g, const std::vector& band, - const std::vector& bandBreaks, float extra) - { - if (bandBreaks.empty() || extra <= 0.0f) - { - return; - } - // 稠密波形(包络带)的光晕:把带**整体外扩** `extra` 像素再填充一次, - // 而不是用宽画笔描整条折线 —— 方波 / 编码器这类含大量近垂直跳变的信号上, - // GDI+ 抗锯齿宽折线的耗时与降级后的顶点数不成比例(实测单条光晕 35ms), - // 而一次 FillPath 只要 1ms 上下,观感也更均匀。 - gd::GraphicsPath path; - path.SetFillMode(gd::FillModeWinding); - for (std::size_t k = 0; k < bandBreaks.size(); ++k) - { - const std::size_t start = bandBreaks[k]; - const std::size_t stop = (k + 1 < bandBreaks.size()) ? bandBreaks[k + 1] : band.size(); - const std::size_t total = stop - start; - if (total < 4 || (total % 2) != 0) - { - continue; - } - const std::size_t half = total / 2; - std::vector expanded; - expanded.reserve(total); - for (std::size_t index = 0; index < half; ++index) - { - const gd::PointF& point = band[start + index]; - expanded.push_back(gd::PointF(point.X, point.Y - extra)); - } - for (std::size_t index = half; index < total; ++index) - { - const gd::PointF& point = band[start + index]; - expanded.push_back(gd::PointF(point.X, point.Y + extra)); - } - path.StartFigure(); - path.AddLines(expanded.data(), static_cast(expanded.size())); - path.CloseFigure(); - } - if (path.GetPointCount() == 0) - { - return; - } - gd::SolidBrush brush(ToGdiColor(WithAlpha(m_theme->accent, 0x66))); - g.FillPath(&brush, &path); - } - void PlotView::PaintHoverHighlight(gd::Graphics& g) { if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE) @@ -5296,6 +5452,12 @@ namespace jdi { return false; } + // 缓存里存的是「快速帧」(关掉了抗锯齿)→ 不能直接命中: + // 必须重画一次全质量版本,否则降级的画面会一直留在屏幕上。 + if (m_sceneDegraded) + { + return false; + } const SceneCacheKey& cached = m_sceneKey; const SceneCacheKey key = MakeSceneCacheKey(); return (key.viewMin == cached.viewMin) && (key.viewMax == cached.viewMax) && @@ -5370,37 +5532,39 @@ namespace jdi mixFloat(static_cast(visual.yMax)); const std::vector& signals = Tab().Lanes()[laneIndex].signals; - // 「边沿计数(中心轴)」的开关与每个被勾选信号各自的均值点数 X / 迭代次数 N - // 会改变网格内的曲线绘制 → 参与指纹。 - mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); - for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) + // 「边沿计数(中心轴)」「均值滤波」「弱波增强」的开关与每条被勾选信号的参数 + // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), + // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, + // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 + if (includeStyles) { - mix(static_cast(item.ref.fileIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - // 「均值滤波」的开关、每个被勾选信号各自的均值点数 X 与迭代次数 N 同样会改变 - // 网格内的曲线绘制 → 参与指纹(面板高度也随勾选数量变化)。 - mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); - for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) - { - mix(static_cast(item.ref.fileIndex)); - mix(static_cast(item.ref.signalIndex)); - mix(static_cast(item.points)); - mix(static_cast(item.iterations)); - } - // 「弱波增强」的开关、增强等级、迭代次数 N 与每个被勾选信号的条目同理。 - mix(Tab().Lanes()[laneIndex].weakBoost ? 1ull : 0ull); - mixFloat(static_cast(Tab().Lanes()[laneIndex].weakBoostLevel)); - mix(static_cast(Tab().Lanes()[laneIndex].weakBoostIterations)); - mix(static_cast(Tab().Lanes()[laneIndex].weakBoostItems.size())); - for (const Lane::WeakBoostItem& item : Tab().Lanes()[laneIndex].weakBoostItems) - { - mix(static_cast(item.ref.fileIndex)); - mix(static_cast(item.ref.signalIndex)); + mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterItems.size())); + for (const Lane::EdgeCenterItem& item : Tab().Lanes()[laneIndex].edgeCenterItems) + { + mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); + for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) + { + mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.iterations)); + } + mix(Tab().Lanes()[laneIndex].weakBoost ? 1ull : 0ull); + mixFloat(static_cast(Tab().Lanes()[laneIndex].weakBoostLevel)); + mix(static_cast(Tab().Lanes()[laneIndex].weakBoostIterations)); + mix(static_cast(Tab().Lanes()[laneIndex].weakBoostItems.size())); + for (const Lane::WeakBoostItem& item : Tab().Lanes()[laneIndex].weakBoostItems) + { + mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.signalIndex)); + } } mix(signals.size()); for (const SignalRef& ref : signals) @@ -5480,6 +5644,11 @@ namespace jdi return false; } + // 叠加预览曲线(均值滤波 / 中心轴 / 弱波增强)的参数变化同样只需局部重画。 + std::vector currentDerived; + CollectSceneDerived(currentDerived); + const bool derivedComparable = (currentDerived.size() == m_sceneDerived.size()); + for (std::size_t laneIndex = 0; laneIndex < current.size(); ++laneIndex) { const std::vector& now = current[laneIndex]; @@ -5496,6 +5665,19 @@ namespace jdi (now[index].lineWidth != before[index].lineWidth) || (now[index].mode != before[index].mode); } + if (!differs && derivedComparable) + { + const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; + const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; + differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || + (nowDerived.meanFilter != beforeDerived.meanFilter) || + (nowDerived.weakBoost != beforeDerived.weakBoost) || + (nowDerived.weakBoostLevelPercent != beforeDerived.weakBoostLevelPercent) || + (nowDerived.weakBoostIterations != beforeDerived.weakBoostIterations) || + (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || + (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || + (nowDerived.weakBoostItems != beforeDerived.weakBoostItems); + } if (differs) { lanes.push_back(static_cast(laneIndex)); @@ -5504,6 +5686,39 @@ namespace jdi return !lanes.empty(); } + void PlotView::CollectSceneDerived(std::vector& out) const + { + out.clear(); + out.resize(m_lanes.size()); + for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) + { + const Lane& lane = Tab().Lanes()[laneIndex]; + SceneDerivedEntry entry; + entry.edgeCenter = lane.edgeCenter ? 1 : 0; + entry.meanFilter = lane.meanFilter ? 1 : 0; + entry.weakBoost = lane.weakBoost ? 1 : 0; + entry.weakBoostLevelPercent = static_cast( + std::lround(std::max(0.0, std::min(1.0, lane.weakBoostLevel)) * 100.0)); + entry.weakBoostIterations = lane.weakBoostIterations; + const auto flatten = [](const auto& items, std::vector& target) + { + target.clear(); + target.reserve(items.size() * 4); + for (const auto& item : items) + { + target.push_back(item.ref.fileIndex); + target.push_back(item.ref.signalIndex); + target.push_back(item.points); + target.push_back(item.iterations); + } + }; + flatten(lane.edgeCenterItems, entry.edgeCenterItems); + flatten(lane.meanFilterItems, entry.meanFilterItems); + flatten(lane.weakBoostItems, entry.weakBoostItems); + out[laneIndex] = std::move(entry); + } + } + void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) { if (lanes.empty() || m_sceneDc == nullptr) @@ -5616,6 +5831,8 @@ namespace jdi m_sceneKey = MakeSceneCacheKey(); m_sceneGeometryHash = SceneGeometryHash(); CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = m_sceneDegraded || m_fastFrame; } gd::RectF PlotView::LaneBadgeRect(int laneIndex) const @@ -5859,6 +6076,8 @@ namespace jdi } ReleaseSceneCache(); + // 场景位图重建 → 面板图层里缓存的"面板背后的场景"随之失效。 + m_panelValid = false; HDC screen = GetDC(nullptr); if (screen == nullptr) @@ -5923,6 +6142,8 @@ namespace jdi m_sceneKey = MakeSceneCacheKey(); m_sceneGeometryHash = SceneGeometryHash(); CollectSceneStyles(m_sceneStyles); + CollectSceneDerived(m_sceneDerived); + m_sceneDegraded = false; m_sceneValid = true; return true; } @@ -5949,7 +6170,12 @@ namespace jdi { m_sceneValid = false; m_sceneStyles.clear(); + m_sceneDerived.clear(); + m_sceneDegraded = false; m_sceneGeometryHash = 0; + // 面板像素图层与场景缓存同源(面板像素里含"面板背后的场景"): + // 信号增删、重命名、参数变化等一律连带作废,避免面板里残留旧波形。 + m_panelValid = false; } void PlotView::ReleaseSceneCache() @@ -6270,6 +6496,15 @@ namespace jdi // 稠密包络:一次 FillPath 填充包络带。 // **必须显式设为缠绕填充规则**:包络带自交(相邻列上下包络交叉)时, // 交替(奇偶)规则会在交叠处挖出空洞。 + // + // 抗锯齿开关:几何保持不变,只在"交互期间"或"梳状带"时关闭。 + // 梳状带(振荡周期≈像素列宽)下抗锯齿的扫描线代价暴涨而观感几乎无差别, + // 实测同一条 2448 顶点的路径 27.6ms → 3.2ms。 + const int savedSmoothing = g.GetSmoothingMode(); + if (m_fastFrame || cached->bandSpiky) + { + g.SetSmoothingMode(gd::SmoothingModeNone); + } gd::GraphicsPath bandPath; bandPath.SetFillMode(gd::FillModeWinding); for (std::size_t k = 0; k < cached->bandBreaks.size(); ++k) @@ -6295,6 +6530,7 @@ namespace jdi gd::SolidBrush brush(ToGdiColor(lineColor)); g.FillPath(&brush, &bandPath); } + g.SetSmoothingMode(static_cast(savedSmoothing)); } else { @@ -7123,21 +7359,54 @@ namespace jdi // 「边沿计数(中心轴)」/「均值滤波」/「弱波增强」开启时,纵向光标还要额外标注 // **派生曲线**在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 // 三条曲线只是曲线来源不同,标注规则完全一致,故共用同一段绘制逻辑。 - const auto paintDerivedCapsule = [&](const std::vector& curve, + // ⚠️ 曲线只取「可见区间 ∪ 两个光标时刻 ± 保护带」的窗口(与绘制共用缓存), + // 因此拖动光标/拖动参数滑块都不会触发整条曲线的重算。 + const auto paintDerivedCapsule = [&](DerivedCurveKind kind, int points, + int iterations, int levelPercent, const std::wstring& suffix) { const int sampleIndex = FindNearestSample(*resolved.file, time); - if (sampleIndex < 0 || - static_cast(sampleIndex) >= curve.size()) + if (sampleIndex < 0) { return; } - const float sampledCurve = curve[static_cast(sampleIndex)]; - if (!std::isfinite(sampledCurve)) + const int otherIndex = + showDelta ? FindNearestSample(*resolved.file, otherTime) : -1; + const std::size_t count = + std::min(resolved.file->time.size(), resolved.data->values.size()); + std::size_t begin = 0; + std::size_t end = 0; + VisibleIndexRange(*resolved.file, count, begin, end); + begin = std::min(begin, static_cast(sampleIndex)); + end = std::max(end, static_cast(sampleIndex) + 1); + if (otherIndex >= 0) + { + begin = std::min(begin, static_cast(otherIndex)); + end = std::max(end, static_cast(otherIndex) + 1); + } + std::size_t spanBegin = 0; + const std::vector* curvePtr = + CurveSpanFor(kind, *resolved.data, points, iterations, levelPercent, begin, + end, spanBegin); + if (curvePtr == nullptr) + { + return; + } + const auto curveValueAt = [curvePtr, spanBegin](int index) -> double + { + if (index < 0 || static_cast(index) < spanBegin || + (static_cast(index) - spanBegin) >= curvePtr->size()) + { + return std::numeric_limits::quiet_NaN(); + } + return static_cast((*curvePtr)[static_cast(index) - + spanBegin]); + }; + const double derivedValue = curveValueAt(sampleIndex); + if (!std::isfinite(derivedValue)) { return; } - const double derivedValue = static_cast(sampledCurve); const float rawCenterY = ValueToY(derivedValue, lane); DataMarker centerMarker = MakeDataMarker( @@ -7152,13 +7421,9 @@ namespace jdi centerText += L"("; if (showDelta) { - const int otherIndex = FindNearestSample(*resolved.file, otherTime); - if (otherIndex >= 0 && - static_cast(otherIndex) < curve.size() && - std::isfinite(curve[static_cast(otherIndex)])) + const double otherDerived = curveValueAt(otherIndex); + if (std::isfinite(otherDerived)) { - const double otherDerived = - static_cast(curve[static_cast(otherIndex)]); const double delta = derivedValue - otherDerived; centerText += (delta < 0.0) ? L"-" : L"+"; centerText += FormatValue(std::fabs(delta)); @@ -7220,9 +7485,8 @@ namespace jdi m_tab->Lanes()[laneIndex].FindEdgeCenterItem(ref); if (centerItem != nullptr) { - paintDerivedCapsule( - MeanFilterCurveFor(*resolved.data, centerItem->points, centerItem->iterations), - L"中心轴"); + paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, centerItem->points, + centerItem->iterations, 0, L"中心轴"); } } if (m_tab->Lanes()[laneIndex].meanFilter) @@ -7232,9 +7496,8 @@ namespace jdi m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref); if (meanItem != nullptr) { - paintDerivedCapsule( - MeanFilterCurveFor(*resolved.data, meanItem->points, meanItem->iterations), - L"均值滤波"); + paintDerivedCapsule(DerivedCurveKind::MEAN_FILTER, meanItem->points, + meanItem->iterations, 0, L"均值滤波"); } } if (m_tab->Lanes()[laneIndex].weakBoost) @@ -7250,7 +7513,7 @@ namespace jdi const int iterations = std::max( kWeakBoostIterationsMin, std::min(kWeakBoostIterationsMax, m_tab->Lanes()[laneIndex].weakBoostIterations)); - paintDerivedCapsule(WeakBoostCurveFor(*resolved.data, levelPercent, iterations), + paintDerivedCapsule(DerivedCurveKind::WEAK_BOOST, 0, iterations, levelPercent, L"弱波增强"); } } @@ -7748,7 +8011,8 @@ namespace jdi } void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, const std::vector& curve) const + const LaneVisual& lane, const std::vector& curve, + std::size_t curveBegin) const { if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) { @@ -7756,31 +8020,15 @@ namespace jdi } const std::vector& time = resolved.file->time; - const std::size_t count = std::min(time.size(), curve.size()); - if (count == 0 || m_plotRect.Width <= 1.0f) + const std::size_t count = std::min(time.size(), resolved.data->values.size()); + if (count == 0 || m_plotRect.Width <= 1.0f || curve.empty()) { return; } - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); std::size_t begin = 0; - std::size_t end = count; - if (resolved.file->timeMonotonic) - { - begin = static_cast( - std::lower_bound(time.begin(), time.end(), viewMin) - time.begin()); - if (begin > 0) - { - --begin; - } - end = static_cast( - std::lower_bound(time.begin(), time.end(), viewMax) - time.begin()); - if (end < count) - { - ++end; - } - } + std::size_t end = 0; + VisibleIndexRange(*resolved.file, count, begin, end); if (end <= begin) { return; @@ -7797,129 +8045,36 @@ namespace jdi const std::uint32_t derived = WithAlpha(HighContrastDerived(StyleColor(*resolved.style)), baseAlpha); - // 稠密(可见采样数超过像素列数)时改用**逐像素列的包络带**绘制。 - // - // ⚠️ 这不是"降级",而是性能与观感的双重需要:派生曲线往往叠加在方波 / 编码器 - // 这类**含大量近垂直跳变**的信号上,而 GDI+ 的抗锯齿折线在这种形状下耗时 - // 与顶点数不成比例 —— 实测 20ms/大格、2448 个顶点时单条曲线要 12~29ms, - // 5 个网格合计上百毫秒(这就是"启用弱波增强后平移格外卡"的直接原因)。 - // 按像素列取上下包络后,每个像素列只保留两个顶点,且包络带做 ±1 列扩展 - // (见 EmitEnvelopeBand 的说明),一次 FillPath 即可完成,实测 1ms 上下, - // 视觉上与细折线完全一致(列宽就是 1 像素)。 - if (visibleCount > static_cast(std::max(1.0f, m_plotRect.Width)) && - resolved.file->timeMonotonic) - { - const double viewSpan = viewMax - viewMin; - const std::size_t columns = static_cast(std::max(1.0f, m_plotRect.Width)); - const float plotLeft = m_plotRect.X; - const float columnWidth = m_plotRect.Width / static_cast(columns); - const float strokeHalf = std::max(1.0f, resolved.style->lineWidth) * m_scale * 0.5f; - - std::vector band; - std::vector bandBreaks; - std::vector segmentColumns; - // 每个像素列的顺序与时间单调一致:从头到尾扫一遍,按列号分桶即可。 - long long currentColumn = -1; - float columnTop = 0.0f; - float columnBottom = 0.0f; - const auto flushColumn = [&]() - { - if (currentColumn < 0) - { - return; - } - BandColumn entry; - entry.x = plotLeft + (static_cast(currentColumn) + 0.5f) * columnWidth; - entry.top = columnTop; - entry.bottom = columnBottom; - segmentColumns.push_back(entry); - }; - const auto flushSegment = [&]() - { - EmitEnvelopeBand(segmentColumns, band, bandBreaks); - segmentColumns.clear(); - }; - - for (std::size_t index = begin; index < end; ++index) - { - const float sampled = curve[index]; - if (!std::isfinite(sampled) || !std::isfinite(time[index])) - { - // 无效采样作为断点:先收尾当前列与当前段,再另起一段。 - flushColumn(); - currentColumn = -1; - flushSegment(); - continue; - } - double ratio = (time[index] - viewMin) / viewSpan; - ratio = std::max(0.0, std::min(1.0, ratio)); - const long long column = std::min( - static_cast(columns) - 1, - static_cast(ratio * static_cast(columns))); - float y = ValueToY(static_cast(sampled), lane); - y = static_cast(ClampDouble(y, limitTop, limitBottom)); - if (column != currentColumn) - { - flushColumn(); - currentColumn = column; - columnTop = y - strokeHalf; - columnBottom = y + strokeHalf; - } - else - { - columnTop = std::min(columnTop, y - strokeHalf); - columnBottom = std::max(columnBottom, y + strokeHalf); - } - } - flushColumn(); - flushSegment(); - - if (!bandBreaks.empty()) - { - gd::GraphicsPath bandPath; - // **必须显式设为缠绕填充规则**:包络带自交(上下包络交叉)时, - // 交替(奇偶)规则会在交叠处挖出空洞。 - bandPath.SetFillMode(gd::FillModeWinding); - for (std::size_t k = 0; k < bandBreaks.size(); ++k) - { - const std::size_t start = bandBreaks[k]; - const std::size_t stop = - (k + 1 < bandBreaks.size()) ? bandBreaks[k + 1] : band.size(); - const std::size_t total = stop - start; - if (total < 4 || (total % 2) != 0) - { - continue; - } - const std::size_t half = total / 2; - bandPath.StartFigure(); - bandPath.AddLines(band.data() + start, static_cast(half)); - bandPath.AddLines(band.data() + start + half, static_cast(half)); - bandPath.CloseFigure(); - } - if (bandPath.GetPointCount() > 0) - { - gd::SolidBrush brush(ToGdiColor(derived)); - g.FillPath(&brush, &bandPath); - } - } - return; - } - - // 稀疏时按折线绘制(顶点预算与波形一致)。 + // 顶点预算与波形一致(每像素列两个顶点)。 + // 交互期间进一步降到 1/2:派生曲线往往有 10 条(5 网格 × 均值滤波 + 弱波增强), + // 顶点数就是绘制耗时;降低密度能让拖动参数滑块保持流畅, + // 松手后下一帧立刻恢复全密度(用户要求的"低分辨率 → 高分辨率")。 std::size_t budget = static_cast(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel; budget = std::max(budget, kMinPolylinePoints); budget = std::min(budget, kMaxPolylinePoints); + if (m_fastFrame) + { + budget = std::max(kMinPolylinePoints, budget / 2); + } const std::size_t stride = std::max(1, visibleCount / std::max(1, budget)); std::vector points; std::vector breaks; points.reserve(std::min(visibleCount, budget) + 1); bool inSegment = false; + // 「陡峭段」计数:用于判断抗锯齿是否值得付出(见下)。 + std::size_t tallSegments = 0; + std::size_t totalSegments = 0; for (std::size_t index = begin; index < end; index += stride) { - const float sampled = curve[index]; + if (index < curveBegin || (index - curveBegin) >= curve.size()) + { + inSegment = false; + continue; + } + const float sampled = curve[index - curveBegin]; if (!std::isfinite(sampled)) { inSegment = false; @@ -7936,6 +8091,11 @@ namespace jdi } else { + if (std::fabs(y - points.back().Y) > lane.rect.Height * 0.5f) + { + ++tallSegments; + } + ++totalSegments; points.push_back(gd::PointF(x, y)); } } @@ -7945,6 +8105,17 @@ namespace jdi return; } + // 派生曲线**恢复为抗锯齿折线**(与"之前"的显示效果一致)。 + // 但抗锯齿在"陡峭段很多"的形状上代价极高(实测 2448 顶点 26~37ms,无抗锯齿仅 3.5ms), + // 而那种形状下抗锯齿的平滑作用几乎看不出来 —— 因此: + // * 交互期间(`m_fastFrame`)一律关闭; + // * 陡峭段占比过高时(方波/编码器这类信号)也关闭。 + const bool spiky = (totalSegments > 0) && (tallSegments * 8 > totalSegments); + const int savedSmoothing = g.GetSmoothingMode(); + if (m_fastFrame || spiky) + { + g.SetSmoothingMode(gd::SmoothingModeNone); + } gd::Pen pen(ToGdiColor(derived), std::max(1.0f, resolved.style->lineWidth) * m_scale); pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); @@ -7959,6 +8130,7 @@ namespace jdi } g.DrawLines(&pen, points.data() + start, static_cast(stop - start)); } + g.SetSmoothingMode(static_cast(savedSmoothing)); } void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, @@ -7968,9 +8140,25 @@ namespace jdi { return; } - // 与「边沿计数(中心轴)」的中心平均值曲线共用同一段绘制逻辑 - // (同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 - PaintDerivedCurve(g, resolved, lane, MeanFilterCurveFor(*resolved.data, points, iterations)); + // 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。 + // 与「边沿计数(中心轴)」共用同一段绘制逻辑 + //(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 + const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size()); + std::size_t begin = 0; + std::size_t end = 0; + VisibleIndexRange(*resolved.file, count, begin, end); + if (end <= begin) + { + return; + } + std::size_t spanBegin = 0; + const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, + points, iterations, 0, begin, end, spanBegin); + if (curve == nullptr) + { + return; + } + PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); } void PlotView::PaintWeakBoostCurve(gd::Graphics& g, const ResolvedSignal& resolved, @@ -7980,8 +8168,294 @@ namespace jdi { return; } + const std::size_t count = std::min(resolved.file->time.size(), resolved.data->values.size()); + std::size_t begin = 0; + std::size_t end = 0; + VisibleIndexRange(*resolved.file, count, begin, end); + if (end <= begin) + { + return; + } + std::size_t spanBegin = 0; + const std::vector* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0, + iterations, levelPercent, begin, end, spanBegin); + if (curve == nullptr) + { + return; + } // 派生曲线一律使用同一段绘制逻辑与配色规则(弱波增强曲线同样如此)。 - PaintDerivedCurve(g, resolved, lane, WeakBoostCurveFor(*resolved.data, levelPercent, iterations)); + PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); + } + + namespace + { + /** @brief FNV-1a 混合(面板像素缓存键共用)。 */ + inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value) + { + hash ^= value; + hash *= 1099511628211ull; + } + } // namespace + + bool PlotView::EnsurePanelCache() + { + // 图层只需覆盖绘图区(面板一律被夹在 m_plotRect 内),且**原点必须与客户区一致**: + // 面板绘制代码用的是客户区绝对坐标,而 `UiText` 走 GDI(不受 GDI+ 变换影响), + // 因此不能靠平移变换把坐标搬进一张小位图里。 + const int width = static_cast(std::ceil(m_area.X + m_area.Width)); + const int height = static_cast(std::ceil(m_area.Y + m_area.Height)); + if (width <= 0 || height <= 0) + { + return false; + } + if (m_panelDc != nullptr && m_panelWidth == width && m_panelHeight == height) + { + return true; + } + + ReleasePanelCache(); + + HDC screen = GetDC(nullptr); + if (screen == nullptr) + { + return false; + } + m_panelDc = CreateCompatibleDC(screen); + m_panelBitmap = CreateCompatibleBitmap(screen, width, height); + ReleaseDC(nullptr, screen); + if (m_panelDc == nullptr || m_panelBitmap == nullptr) + { + ReleasePanelCache(); + return false; + } + m_panelOldBitmap = SelectObject(m_panelDc, m_panelBitmap); + m_panelWidth = width; + m_panelHeight = height; + m_panelValid = false; + return true; + } + + void PlotView::ReleasePanelCache() + { + m_panelValid = false; + m_panelSlots.clear(); + if (m_panelDc != nullptr) + { + if (m_panelOldBitmap != nullptr) + { + SelectObject(m_panelDc, m_panelOldBitmap); + m_panelOldBitmap = nullptr; + } + DeleteDC(m_panelDc); + m_panelDc = nullptr; + } + if (m_panelBitmap != nullptr) + { + DeleteObject(m_panelBitmap); + m_panelBitmap = nullptr; + } + m_panelWidth = 0; + m_panelHeight = 0; + } + + bool PlotView::PanelRectInsideLayer(const gd::RectF& rect) const + { + if (m_panelDc == nullptr) + { + return false; + } + return rect.X >= 0.0f && rect.Y >= 0.0f && + rect.X + rect.Width <= static_cast(m_panelWidth) && + rect.Y + rect.Height <= static_cast(m_panelHeight); + } + + std::uint64_t PlotView::PanelGlobalKey() const + { + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixDouble = [&mix](double value) + { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixRect = [&mixFloat](const gd::RectF& rect) + { + mixFloat(rect.X); + mixFloat(rect.Y); + mixFloat(rect.Width); + mixFloat(rect.Height); + }; + const auto mixRef = [&mix](const SignalRef& ref) + { + mix(ref.IsValid() ? 1ull : 0ull); + mix(static_cast(ref.fileIndex)); + mix(static_cast(ref.signalIndex)); + }; + + mixFloat(m_scale); + mix(reinterpret_cast(m_theme)); + mix(reinterpret_cast(m_fonts)); + mixRect(m_plotRect); + mixRect(m_area); + mix(static_cast(m_dragMode)); + mix(static_cast(m_laneDragFrom + 2)); + mix(static_cast(m_laneDragTarget + 2)); + mix(static_cast(m_laneDragMouseY + 4096)); + mix(static_cast(m_hoverLane + 2)); + mix(m_cursorHover ? 1ull : 0ull); + mix(static_cast(m_hoverCursor + 2)); + mix(static_cast(m_activeCursor)); + mix(m_dragMoved ? 1ull : 0ull); + mixRect(m_rubberRect); + mixRef(m_hoverSignal); + mixRef(m_hoverLegend); + mixRef(m_dragSignal); + mixRef(SelectedRef()); + mix(static_cast(m_boostDragLane + 2)); + mix(static_cast(m_edgePanelDragLane + 2)); + mix(static_cast(m_edgePanelDragKind)); + + // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 + mix(static_cast(Tab().CursorMode())); + mixDouble(Tab().CursorTime()); + mixDouble(Tab().CursorTimeAlt()); + mix(EdgeRangeActive() ? 1ull : 0ull); + mixDouble(EdgeRangeMin()); + mixDouble(EdgeRangeMax()); + + // 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变: + // 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。 + const bool mouseDrivenOverlay = + m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || + m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || + HintNeedsAnimation(); + mix(mouseDrivenOverlay ? 1ull : 0ull); + if (mouseDrivenOverlay) + { + mix(static_cast(m_mouseX + 4096)); + mix(static_cast(m_mouseY + 4096)); + } + + for (const LaneVisual& visual : m_lanes) + { + mixRect(visual.rect); + mixFloat(static_cast(visual.yMin)); + mixFloat(static_cast(visual.yMax)); + } + + if (m_tab != nullptr) + { + const std::vector& lanes = m_tab->Lanes(); + for (const Lane& lane : lanes) + { + mixFloat(static_cast(lane.edgePanelX)); + mixFloat(static_cast(lane.edgePanelY)); + mixFloat(static_cast(lane.edgeCenterPanelX)); + mixFloat(static_cast(lane.edgeCenterPanelY)); + mixFloat(static_cast(lane.meanFilterPanelX)); + mixFloat(static_cast(lane.meanFilterPanelY)); + mixFloat(static_cast(lane.weakBoostPanelX)); + mixFloat(static_cast(lane.weakBoostPanelY)); + mix(lane.edgePanelOpen ? 1ull : 0ull); + mix(lane.edgeCenterPanelOpen ? 1ull : 0ull); + mix(lane.meanFilterPanelOpen ? 1ull : 0ull); + mix(lane.weakBoostPanelOpen ? 1ull : 0ull); + mix(lane.edgeValueValid ? 1ull : 0ull); + mixDouble(lane.edgeValue); + } + } + return hash; + } + + std::uint64_t PlotView::PanelSlotKey(int laneIndex, EdgePanelKind kind, + const EdgePanelLayout& layout) const + { + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) { MixPanelKey(hash, value); }; + const auto mixFloat = [&mix](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mix(bits); + }; + const auto mixRect = [&mixFloat](const gd::RectF& rect) + { + mixFloat(rect.X); + mixFloat(rect.Y); + mixFloat(rect.Width); + mixFloat(rect.Height); + }; + mix(static_cast(laneIndex + 2)); + mix(static_cast(static_cast(kind) + 2)); + mixRect(layout.panel); + mixRect(layout.title); + mixRect(layout.close); + mixRect(layout.edit); + mixRect(layout.button); + mixRect(layout.slider); + mixRect(layout.sliderIterations); + mix(static_cast(layout.rows)); + for (const gd::RectF& rect : layout.rowChecks) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowPoints) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowIterations) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowStats) + { + mixRect(rect); + } + + const Lane& lane = Tab().Lanes()[static_cast(laneIndex)]; + for (const SignalRef& ref : layout.rowRefs) + { + mix(ref.IsValid() ? 1ull : 0ull); + mix(static_cast(ref.fileIndex)); + mix(static_cast(ref.signalIndex)); + // 勾选状态与参数直接决定行文本与行高。 + mix(lane.MeanFilterEnabled(ref) ? 1ull : 0ull); + mix(lane.EdgeCenterEnabled(ref) ? 1ull : 0ull); + mix(lane.WeakBoostEnabled(ref) ? 1ull : 0ull); + const Lane::MeanFilterItem* meanItem = lane.FindMeanFilterItem(ref); + mix(static_cast(meanItem != nullptr ? meanItem->points + 2 : 0)); + mix(static_cast(meanItem != nullptr ? meanItem->iterations + 2 : 0)); + const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); + mix(static_cast(centerItem != nullptr ? centerItem->points + 2 : 0)); + mix(static_cast(centerItem != nullptr ? centerItem->iterations + 2 : 0)); + } + // 滑块数值(等级百分比 + 迭代次数)只显示在「弱波增强」面板上: + // ⚠️ 必须限定 kind,否则拖等级滑块会把同一网格的均值滤波 / 中心轴面板 + // 也判为脏,再经"重叠级联"扩散到其他网格,等于所有面板都重画(实测 40ms/帧)。 + if (kind == EdgePanelKind::BOOST) + { + mixFloat(static_cast(lane.weakBoostLevel)); + mix(static_cast(lane.weakBoostIterations + 2)); + } + + // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 + const float margin = 6.0f * m_scale; + const gd::RectF hot(layout.panel.X - margin, layout.panel.Y - margin, + layout.panel.Width + margin * 2.0f, + layout.panel.Height + margin * 2.0f); + if (UiPointInRect(hot, static_cast(m_mouseX), static_cast(m_mouseY))) + { + mix(static_cast(m_mouseX + 4096)); + mix(static_cast(m_mouseY + 4096)); + } + return hash; } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) @@ -8090,6 +8564,18 @@ namespace jdi // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- // 「边沿计数」「边沿计数(中心轴)」「均值滤波」「弱波增强」共用同一套样式, // 仅标题、内容行与底部按钮不同。 + // + // 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画, + // 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms, + // 而贴图 <1ms —— 拖动弱波增强滑块时的主要卡顿就来自这一层)。 + std::vector nextSlots; + std::vector dirtyRects; + const std::uint64_t panelGlobalKey = PanelGlobalKey(); + // 编辑态(输入框 + 插入符)逐个面板变化太细碎,直接整体不缓存。 + const bool panelCacheReady = + (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); + const bool panelGlobalSame = + panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; for (int pass = 0; pass < kEdgePanelKindCount; ++pass) { const EdgePanelKind kind = kEdgePanelKinds[pass]; @@ -8109,6 +8595,58 @@ namespace jdi continue; } + // 面板自身的内容指纹;命中缓存时直接贴图,本帧不再重画这个面板。 + // ⚠️ 缓存像素里含**它与它下面的一切**(面板背后的波形、被它遮住的其他面板), + // 因此只要有一个面板重画,所有「在它之后绘制、且与它重叠」的面板也必须重画, + // 否则后画者会把旧像素重新盖回去(面板默认位置本来就常常叠在一起)。 + const float cacheMargin = 4.0f * m_scale; + const gd::RectF cachedRect(panel.X - cacheMargin, panel.Y - cacheMargin, + panel.Width + cacheMargin * 2.0f, + panel.Height + cacheMargin * 2.0f); + bool forcedDirty = false; + for (const gd::RectF& dirtyRect : dirtyRects) + { + if (dirtyRect.IntersectsWith(cachedRect)) + { + forcedDirty = true; + break; + } + } + + std::uint64_t slotKey = 0; + const std::size_t slotIndex = nextSlots.size(); + if (panelCacheReady) + { + slotKey = PanelSlotKey(static_cast(index), kind, layout); + if (panelGlobalSame && !forcedDirty) + { + const PanelSlot* hit = nullptr; + for (const PanelSlot& slot : m_panelSlots) + { + if (slot.laneIndex == static_cast(index) && + slot.kind == static_cast(kind)) + { + hit = &slot; + break; + } + } + if (hit != nullptr && hit->key != 0 && hit->key == slotKey) + { + HDC target = g.GetHDC(); + if (target != nullptr) + { + BitBlt(target, hit->x0, hit->y0, hit->x1 - hit->x0, hit->y1 - hit->y0, + m_panelDc, hit->x0, hit->y0, SRCCOPY); + g.ReleaseHDC(target); + } + nextSlots.push_back(*hit); + continue; + } + } + } + nextSlots.push_back(PanelSlot()); + dirtyRects.push_back(cachedRect); + const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; @@ -8270,6 +8808,11 @@ namespace jdi { const float rowHeight = std::max(kWeakBoostSliderRowHeight * m_scale, layout.slider.Height); + // 两行滑块的说明文字必须**左对齐到同一列**(用户明确要求): + // 列宽取两条标签中较宽者,与 `EdgePanelLayoutFor` 里定位轨道的方式一致。 + const float labelColumnWidth = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostLevelLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kWeakBoostIterationsLabel)); const auto paintSliderRow = [&](const gd::RectF& track, const wchar_t* label, double ratio, const std::wstring& valueText, bool active) @@ -8278,11 +8821,10 @@ namespace jdi { return; } - const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); + const float labelLeft = track.X - 6.0f * m_scale - labelColumnWidth; UiText(g, label, m_fonts->uiSmall, - gd::RectF(track.X - 6.0f * m_scale - labelWidth, - track.Y + track.Height * 0.5f - rowHeight * 0.5f, - labelWidth, rowHeight), + gd::RectF(labelLeft, track.Y + track.Height * 0.5f - rowHeight * 0.5f, + labelColumnWidth, rowHeight), m_theme->textMuted, TextAlign::LEFT); // 与样式区/拾色器共用同一套滑块绘制(轨道 + 已填充部分 + 圆钮)。 @@ -8367,8 +8909,46 @@ namespace jdi TextAlign::CENTER); } } + + // 面板画完了:把它刚画进后备缓冲区的像素(连同缓存键)存进面板图层, + // 下一帧只要键不变就直接贴图。矩形外的 4 逻辑像素边距用于容纳投影。 + PanelSlot& stored = nextSlots[slotIndex]; + stored.laneIndex = static_cast(index); + stored.kind = static_cast(kind); + stored.key = 0; + if (panelCacheReady && slotKey != 0) + { + const int x0 = std::max(0, static_cast(std::floor(cachedRect.X))); + const int y0 = std::max(0, static_cast(std::floor(cachedRect.Y))); + const int x1 = std::max(x0, static_cast(std::ceil(cachedRect.X + cachedRect.Width))); + const int y1 = std::max(y0, static_cast(std::ceil(cachedRect.Y + cachedRect.Height))); + const gd::RectF layerRect(static_cast(x0), static_cast(y0), + static_cast(x1 - x0), static_cast(y1 - y0)); + if (x1 > x0 && y1 > y0 && PanelRectInsideLayer(layerRect)) + { + HDC source = g.GetHDC(); + if (source != nullptr) + { + const BOOL ok = BitBlt(m_panelDc, x0, y0, x1 - x0, y1 - y0, source, x0, y0, + SRCCOPY); + g.ReleaseHDC(source); + if (ok != FALSE) + { + stored.key = slotKey; + stored.x0 = x0; + stored.y0 = y0; + stored.x1 = x1; + stored.y1 = y1; + } + } + } + } } } + + m_panelSlots = std::move(nextSlots); + m_panelGlobalKey = panelGlobalKey; + m_panelValid = panelCacheReady; } void PlotView::PaintEditField(gd::Graphics& g)