// PlotView 实现分片 —— 内容指纹与缓存键("为什么这一帧可以复用上一帧的像素")。 // // 本分片只包含 `PlotView` 的**成员函数定义**(从 `PlotViewPaint.cpp` 机械搬移,行为与搬移前逐字节一致)。 // 职责: // * 场景缓存键 `SceneCacheKey` 的构造与比较; // * 场景内容指纹(`SceneContentHash` / `SceneGeometryHash` / `SceneDecorHash`); // * 逐信号样式快照与"只改了外观"的网格定位(`CollectSceneStyles` / `FindStyleOnlyLanes`); // * 派生曲线参数快照(`CollectSceneDerived`); // * 样式补丁帧(贴图 → 只重画受影响网格 → 写回缓存)。 // // ⚠️ 硬约束:任何"会影响像素"的状态都必须进指纹;漏一个字段的症状是"贴旧像素" // (本项目历史上至少 4 次真实 BUG 都是这个原因)。 // ⚠️ 本文件**不做**像素分配与贴图(那些在 `PlotViewRaster.cpp`),也不做绘制(`PlotViewPaint.cpp`)。 #include "PlotViewInternal.hpp" namespace jdi { PlotView::SceneCacheKey PlotView::MakeSceneCacheKey() const { SceneCacheKey key; key.viewMin = Tab().ViewMin(); key.viewMax = Tab().ViewMax(); key.laneScroll = Tab().LaneScroll(); key.laneCount = static_cast(m_lanes.size()); key.areaX = m_area.X; key.areaY = m_area.Y; key.areaW = m_area.Width; key.areaH = m_area.Height; key.scale = m_scale; const SignalRef halo = SceneHaloRef(); key.haloGroup = halo.groupIndex; key.haloSignal = halo.signalIndex; key.contentHash = SceneContentHash(); return key; } bool PlotView::SceneCacheMatches() const { if (!m_sceneValid) { 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) && (key.laneScroll == cached.laneScroll) && (key.laneCount == cached.laneCount) && (key.areaX == cached.areaX) && (key.areaY == cached.areaY) && (key.areaW == cached.areaW) && (key.areaH == cached.areaH) && (key.scale == cached.scale) && (key.haloGroup == cached.haloGroup) && (key.haloSignal == cached.haloSignal) && (key.contentHash == cached.contentHash); } SignalRef PlotView::SceneHaloRef() const { // 只有拖拽中的信号光晕是“整个拖拽过程都不变”的,可以安全地绘入静态场景缓存; // 悬停光晕随鼠标移动,必须画在缓存之外(见 PaintHoverHighlight)。 if (m_dragMode == PlotDragMode::MOVE_SIGNAL && m_dragSignal.IsValid()) { return m_dragSignal; } return SignalRef(); } std::uint64_t PlotView::SceneContentHash(bool includeStyles, bool includeDerived, bool includeLaneRange, bool includeLaneScroll) const { // FNV-1a:把“视图几何之外的一切影响绘图区像素的内容”折叠成一个指纹。 // 这样颜色/线宽/透明度/样式/重命名/信号增删等变化都会自动让缓存失效, // 不需要在各处手工调用 InvalidateSceneCache(容易漏)。 // `includeStyles == false` 时跳过逐信号的外观样式(颜色/不透明度/线宽/模式), // 得到的就是几何指纹:它一致说明“只有外观样式变了”。 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)); } }; mixFloat(m_plotRect.X); mixFloat(m_plotRect.Y); mixFloat(m_plotRect.Width); mixFloat(m_plotRect.Height); mixFloat(m_laneContentHeight); mix(m_scrollBarVisible ? 1ull : 0ull); // ⚠️ 网格纵向滚动量必须参与**静态场景缓存**的指纹:它只改变各网格的绘制位置。 // 若不参与,平移宽缓冲区 / 静态场景缓存会把旧滚动位置的像素当成当前内容贴回, // 表现为“网格位置错位 / 拖影 / 残留”等错误元素。 // ⚠️ 但它是**纯屏幕落点**(不影响任何网格内容),因此“宽基底可用性”判据 //(`SceneGeometryHash`)把它排除:网格滚动时 `BlitWideBuffer` 本来就按 `lane.rect` // 逐网格贴图,内容完全复用 —— 这样“网格滚动到可视区 / 回到最上方”这类 // **每帧都在改滚动量的动画**才不会退化成每帧整幅重绘(用户 2026-10-17 报告卡顿)。 if (includeLaneScroll) { mixFloat(Tab().LaneScroll()); } // 选中信号会改变图例条目的底色与文字颜色(绘制在缓存的静态场景里), // 因此必须参与指纹:否则在左侧信号面板点选后图例不会高亮联动。 { const SignalRef selected = SelectedRef(); mix(selected.IsValid() ? 1ull : 0ull); mix(static_cast(selected.groupIndex)); mix(static_cast(selected.signalIndex)); } for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const LaneVisual& visual = m_lanes[laneIndex]; // ⚠️ 各网格的屏幕纵向位置同样只是“贴图落点”(见 `includeLaneScroll` 的说明)。 if (includeLaneScroll) { mixFloat(visual.rect.Y); } mixFloat(visual.rect.Height); // ⚠️ 幅值范围只在 `includeLaneRange` 时计入:拖拽平移时的纵向分量会每帧小幅改动它, // 若参与“宽基底可用性”判据,每一帧都会退化成整幅重绘(质量下降 + 卡顿)。 if (includeLaneRange) { mixFloat(static_cast(visual.yMin)); mixFloat(static_cast(visual.yMax)); } const std::vector& signals = Tab().Lanes()[laneIndex].signals; // 「边沿计数(中心轴)」「均值滤波」「弱峰调整」的开关与每条被勾选信号的参数 // 会改变网格内的曲线绘制。它们**只影响网格内容**(不影响网格结构/几何), // 因此只在 includeStyles 时计入 —— 于是拖参数滑块可以走“局部重画”快路径, // 而不必整幅重绘(`FindStyleOnlyLanes` 用 `m_sceneDerived` 定位受影响的网格)。 // `includeDerived == false` 时连这些参数也不计入(用于装饰层指纹:图例/Y 轴刻度 // 与派生曲线参数无关,拖滑块时装饰层无需失效)。 if (includeStyles && includeDerived) { 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.groupIndex)); 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.groupIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.points)); mix(static_cast(item.iterations)); } mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) { // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 mix(static_cast(item.ref.groupIndex)); mix(static_cast(item.ref.signalIndex)); mix(static_cast(item.amountPercent)); mix(static_cast(item.iterations)); } } mix(signals.size()); for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); mix(static_cast(ref.groupIndex)); mix(static_cast(ref.signalIndex)); mix(resolved.visible ? 1ull : 0ull); if (resolved.data == nullptr || resolved.style == nullptr) { continue; } // 以下四项是“只影响外观”的样式:仅在 includeStyles 时计入指纹。 // (不参与时得到的就是 SceneGeometryHash:它一致说明只有外观变了, // 于是可以只重画颜色变了的网格,而不必整幅重绘。) if (includeStyles) { mix(static_cast(resolved.style->color)); mixFloat(resolved.style->opacity); mixFloat(resolved.style->lineWidth); mix(static_cast(resolved.style->mode)); // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 mixFloat(resolved.style->gain); mix(resolved.style->absolute ? 1ull : 0ull); mix(resolved.style->mirrorVertical ? 1ull : 0ull); mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); // 偏移(Y 平移 + 时间平移)同样会改变波形。 mixFloat(resolved.style->offsetY); mixFloat(static_cast(resolved.style->offsetTime)); } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->name); if (resolved.group != nullptr) { mixText(resolved.group->name); } } } return hash; } std::uint64_t PlotView::SceneGeometryHash() const { // 外观样式不参与;各网格的幅值范围也不参与(那由逐网格图层指纹负责, // 这样拖拽时的纵向分量不会把整幅重绘的快路径打掉); // ⚠️ 网格纵向滚动量 / 各网格屏幕纵向位置也不参与:那只是图层的**贴图落点**, // 不影响任何网格的像素内容(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图), // 因此滚动网格时可以继续复用宽基底(否则“滚动到可视区 / 回到最上方”的 // 每一帧都会退化成整幅重绘 —— 用户 2026-10-17 报告的滚动动画卡顿)。 return SceneContentHash(false, true, false, false); } std::uint64_t PlotView::SceneDecorHash() const { // 装饰层(图例 + Y 轴刻度)的指纹:含外观样式,但**不含派生曲线参数** //(拖弱峰调整等级/迭代次数滑块时图例与刻度不变,装饰层缓存不应失效)。 return SceneContentHash(true, false); } void PlotView::CollectSceneStyles(std::vector>& out) const { out.clear(); out.resize(m_lanes.size()); for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const std::vector& signals = Tab().Lanes()[laneIndex].signals; std::vector& lane = out[laneIndex]; lane.reserve(signals.size()); for (const SignalRef& ref : signals) { const ResolvedSignal resolved = ResolveSignal(ref); SceneStyleEntry entry; if (resolved.style != nullptr) { entry.color = resolved.style->color; entry.opacity = resolved.style->opacity; entry.lineWidth = resolved.style->lineWidth; entry.mode = static_cast(resolved.style->mode); entry.gain = resolved.style->gain; entry.absolute = resolved.style->absolute; entry.mirrorVertical = resolved.style->mirrorVertical; entry.mirrorHorizontal = resolved.style->mirrorHorizontal; entry.offsetY = resolved.style->offsetY; entry.offsetTime = resolved.style->offsetTime; } lane.push_back(entry); } } } bool PlotView::FindStyleOnlyLanes(std::vector& lanes) const { lanes.clear(); if (!m_sceneValid || m_sceneGeometryHash != SceneGeometryHash()) { // 网格结构 / 视图几何 / 信号集合 / 图例文案有变化:必须整幅重绘。 return false; } // ⚠️ **必须额外确认“视图几何与缓存完全一致”**:`SceneGeometryHash()` 刻意不含视界, // 所以它相等**并不能**说明视图没变。若视界已经变了还走“只重画受影响网格”的局部补丁, // 就会把缓存里**旧视界**的画面(时间轴、其它网格、底色)留在屏上 —— 表现为用户反馈的 // “拖影 / 残留 / 错误元素”。因此这里逐项比较除内容指纹之外的缓存键。 { const SceneCacheKey key = MakeSceneCacheKey(); if (key.viewMin != m_sceneKey.viewMin || key.viewMax != m_sceneKey.viewMax || key.laneScroll != m_sceneKey.laneScroll || key.laneCount != m_sceneKey.laneCount || key.areaX != m_sceneKey.areaX || key.areaY != m_sceneKey.areaY || key.areaW != m_sceneKey.areaW || key.areaH != m_sceneKey.areaH || key.scale != m_sceneKey.scale || key.haloGroup != m_sceneKey.haloGroup || key.haloSignal != m_sceneKey.haloSignal) { return false; } } std::vector> current; CollectSceneStyles(current); if (current.size() != m_sceneStyles.size()) { 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]; const std::vector& before = m_sceneStyles[laneIndex]; if (now.size() != before.size()) { return false; } bool differs = false; for (std::size_t index = 0; index < now.size() && !differs; ++index) { // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, // 一直残留到某次无关的失效。 differs = !(now[index] == before[index]); } if (!differs && derivedComparable) { const SceneDerivedEntry& nowDerived = currentDerived[laneIndex]; const SceneDerivedEntry& beforeDerived = m_sceneDerived[laneIndex]; differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || (nowDerived.meanFilter != beforeDerived.meanFilter) || (nowDerived.weakPeak != beforeDerived.weakPeak) || (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); } if (differs) { lanes.push_back(static_cast(laneIndex)); } } 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.weakPeak = lane.weakPeak ? 1 : 0; const auto flatten = [](const auto& items, std::vector& target, auto valueOf) { target.clear(); target.reserve(items.size() * 4); for (const auto& item : items) { target.push_back(item.ref.groupIndex); target.push_back(item.ref.signalIndex); // 均值滤波/中心轴取 points,弱峰调整取 amountPercent(由 `valueOf` 决定)。 target.push_back(static_cast(valueOf(item))); target.push_back(item.iterations); } }; flatten(lane.edgeCenterItems, entry.edgeCenterItems, [](const Lane::EdgeCenterItem& item) { return item.points; }); flatten(lane.meanFilterItems, entry.meanFilterItems, [](const Lane::MeanFilterItem& item) { return item.points; }); flatten(lane.weakPeakItems, entry.weakPeakItems, [](const Lane::WeakPeakItem& item) { return item.amountPercent; }); out[laneIndex] = std::move(entry); } } void PlotView::RepaintSceneCachePatch(gd::Graphics& g, const std::vector& lanes) { if (lanes.empty() || m_sceneDc == nullptr) { return; } // 重画范围 = 受影响网格与绘图区的交集(并且不超出缓存区域)。 float top = 1.0e9f; float bottom = -1.0e9f; for (const int laneIndex : lanes) { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) { continue; } const gd::RectF& rect = m_lanes[static_cast(laneIndex)].rect; top = std::min(top, rect.Y); bottom = std::max(bottom, rect.Y + rect.Height); } if (bottom <= top) { return; } top = std::max(top, m_plotRect.Y); bottom = std::min(bottom, m_plotRect.Y + m_plotRect.Height); if (bottom <= top) { return; } // ⚠️ 补丁矩形必须**向内**对齐到整数像素(ceil 上边界、floor 下边界): // 网格矩形是浮点数,而 GDI `FillRect`/`BitBlt` 会按 floor/ceil 向外取整, // "向外"会多擦掉/多写回一整行,那一行属于相邻网格(例如它的分界线), // 于是补丁边界上会留下一像素接缝。向内对齐后,边界外的行一律由缓存提供 // (缓存内容与整幅重绘逐像素一致,已由对照帧验证)。 const int x0 = std::max(static_cast(std::ceil(m_area.X)), static_cast(std::ceil(m_area.X))); const int y0 = std::max(static_cast(std::ceil(top)), static_cast(std::ceil(m_area.Y))); const int x1 = std::min(static_cast(std::floor(m_area.X + m_area.Width)), static_cast(std::floor(m_area.X + m_area.Width))); const int y1 = std::min(static_cast(std::floor(bottom)), static_cast(std::floor(m_area.Y + m_area.Height))); if (x1 <= x0 || y1 <= y0) { return; } const gd::RectF patch(static_cast(x0), static_cast(y0), static_cast(x1 - x0), static_cast(y1 - y0)); // 补丁区域必须被**完整**重画:凡是与它相交的网格都要重画一遍 // (补丁已向内对齐,正常只会命中受影响的那几个网格)。 std::vector repaint; for (std::size_t laneIndex = 0; laneIndex < m_lanes.size(); ++laneIndex) { const gd::RectF& rect = m_lanes[laneIndex].rect; if (rect.Y < static_cast(y1) && rect.Y + rect.Height > static_cast(y0)) { repaint.push_back(static_cast(laneIndex)); } } if (repaint.empty()) { return; } // 与整幅重绘保持完全相同的绘制顺序:底色 → 逐网格内容(PaintLane 会与当前裁剪区求交)。 { const int saveState = g.Save(); g.SetClip(patch, gd::CombineModeIntersect); // ⚠️ 底色填充必须**按补丁矩形本身**填充,不能用 m_area / m_plotRect: // `UiFillRect` 对大面积不透明矩形会走 GDI `FillRect` 快路径,而 GDI 绘制 // **不受 GDI+ 裁剪区约束**,会把已经贴好的缓存内容整片覆盖掉。 UiFillRect(g, patch, m_theme->plotBg); const float cardTop = std::max(patch.Y, m_plotRect.Y); const float cardBottom = std::min(patch.Y + patch.Height, m_plotRect.Y + m_plotRect.Height); if (cardBottom > cardTop) { UiFillRect(g, gd::RectF(patch.X, cardTop, patch.Width, cardBottom - cardTop), m_theme->cardBg); } const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); const double span = viewMax - viewMin; const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); std::vector timeTicks; BuildTicks(viewMin, viewMax, timeStep, timeTicks); // ⚠️ **补丁必须就地绘制装饰**(而不是"跳过装饰"):补丁范围覆盖整个受影响网格 // (含网格左缘的 Y 轴胶囊与图例),若跳过绘制,这部分像素会被网格底色/波形覆盖, // 而补丁结果还会写回场景缓存 —— 表现为"改样式时图例/Y 轴读数闪掉一下"。 const bool savedUseDecorCache = m_useDecorCache; m_useDecorCache = false; for (const int laneIndex : repaint) { if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size())) { PaintLane(g, laneIndex, m_lanes[static_cast(laneIndex)], timeTicks); } } m_useDecorCache = savedUseDecorCache; g.Restore(saveState); } // 把重画结果写回缓存位图,使缓存与当前样式一致(下一帧即回到"完全命中"快路径)。 { HDC target = g.GetHDC(); if (target != nullptr) { BitBlt(m_sceneDc, x0 - m_sceneX, y0 - m_sceneY, x1 - x0, y1 - y0, target, x0, y0, SRCCOPY); g.ReleaseHDC(target); } } m_sceneKey = MakeSceneCacheKey(); m_sceneGeometryHash = SceneGeometryHash(); CollectSceneStyles(m_sceneStyles); CollectSceneDerived(m_sceneDerived); m_sceneDegraded = m_sceneDegraded || m_fastFrame; } } // namespace jdi