上下/斜向平移卡顿(图层与面板都缺纵向预渲染)+ 深放大拖拽光标跑飞

用户报告:Y 轴自适应 + 选区放大到约 1~2ms 一大格后,中键拖拽移动视界"左右很流畅,上下或
斜向会卡顿",并猜测"上下移动时没有像左右那样的预渲染缓存";另有"小缩放区间拖拽光标跑飞"。

一、先验证猜测(离屏量化,不凭感觉)
panperf 新增:纯竖直拖拽(只改被拖网格幅值、视界不动)+ 逐帧统计图层重画次数 + 5 网格全开
4 类悬浮面板(20 个)后的水平/竖直对比。结果:
  * 无面板:水平 6.29ms/帧,竖直 5.26ms/帧,但竖直**每帧都整块重画那个网格的图层**(40/40)。
  * 20 个面板:水平 37.7ms/帧,竖直 **166.6ms/帧** —— 这就是用户说的"上下卡顿"。
结论:猜测部分成立(纵向确实没有预渲染),但主因是另一处:浮动面板像素图层的**全局键**里含
visual.yMin/yMax,平移的纵向分量每帧小改幅值 → 整层 20 个面板每帧全部重画。水平平移不改幅值,
所以只有竖直/斜向才触发 —— 正好解释"左右流畅、上下卡顿"的不对称。

二、图层改为二维预渲染(横向 ±半屏 / 纵向 ±半个网格高)
- LaneLayer 增加 marginY / anchorYMax / pxPerValue;位图高度 = ceil(可见网格高) + 2*marginY。
- 幅值范围按**像素行**精确扩展(上扩 marginY/pxPerValue,下扩 (bitmapHeight - marginY - laneHeight)
  /pxPerValue):只有这样"扩展跨度 / 画布行数"才严格等于前台的 px/数值,可见网格在画布里的映射
  才与整幅渲染逐像素同源(按固定数值量扩展会留下 <1px 的纵向缩放误差,水平残差会从 77k 涨到 105k)。
- 图层里的幅值刻度改以**前台可见网格**的跨度/高度取 NiceStep(m_wideTickSpan / m_wideTickHeight):
  画布更高更宽,若按画布值取步长,网格线密度与前台不一致(贴图后拖动时网格线跳变)。
- 贴图:srcY = round(top - lane.Y) + marginY + round((anchorYMax - 当前 yMax) * pxPerValue);
  超出画布上下端时只贴有像素的那一段;偏移超出余量才排队重画(重画即重新居中)。
- LaneLayerHash 只计幅值**跨度**、不计绝对范围(绝对范围是贴图偏移的来源)。
- 网格分界线移出图层(m_renderWide 时跳过,改由 PaintLaneDividers() 在宽基底路径补画一次):
  它锚定在网格的屏幕位置,纵向偏移贴图会把它带着一起跑。

三、面板像素图层不再被幅值范围击穿
PanelGlobalKey() 去掉 visual.yMin/yMax(面板内容与幅值区间无关)。代价仅是 95.7% 不透明底背后
那 4.3% 透出的底层像素可能旧一帧(色差有界 ≤ 0.043*255 ≈ 11 级),与既有的"旧一帧"取舍一致。

四、深放大下拖拽光标跑飞
历史:388d3b6/67f659a 的"视图变化后夹光标"被 eaf5b9c 刻意删除(改为光标可滞留在视界外 + 贴边读数
+ 方向三角);但深放大下一屏平移就是几十倍可视跨度,光标瞬间被甩到视界外很远 = 用户看到的"跑飞"。
修法(区分连续手势与离散视图变化):
- 新增 ClampCursorsAfterPan(),在**平移**路径调用:PAN 拖拽(中键/左键)、Shift+滚轮、方向键。
- 离散视图变化(选区放大 / 适应视图 / 按网格时间轴展开)仍然不移动光标(先量好再自由探索,
  贴边读数可读、可拖回来)——不要把夹取加回这几处。
- 抓取被钉在边缘的光标读数时不再沿用抓取偏移(CursorOffViewSide → m_cursorGrabOffset = 0),
  否则首次移动会把目标送到视界外再被夹回边缘("一拖就跳很远 / 光标钉在边缘不动")。

五、验证(全部离屏,不占用鼠标/键盘/屏幕)
- uitest 432/432:[AH] 改为"幅值平移不再让图层失效(只改贴图偏移)、幅值跨度变化仍失效"并新增
  "纯竖直拖拽 8 帧 0 次图层重画";新增 [AI]"贴边读数抓取后光标落在鼠标处(误差 0.0px)、
  中键平移 1.5 屏 0 帧出界且跟手"。
- panperf(VERY_BIG_DATA、5 网格、1~2ms 一大格):竖直中位 3.8~5.0ms、40 帧仅 1 次图层重画
  (修复前 40/40);水平 5.7~6.5ms(与修复前 6.29ms 持平);20 个面板时竖直 166.6 → 24.8ms/帧,
  水平 37.7 → 38.8ms/帧(不对称消失)。整幅质量残差与修复前同量级(水平 77k~81k ↔ 77k~81k)。
- layercheck:clobbers=0/24、pan frames wrong=0/4、居中图层 vs 整幅重绘逐像素 0 差异。
- 新增探针 .preflight/cursorcheck(真文件 + 真缩放)30/30。
- make clean && make all 零警告。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-12 16:09:41 +08:00
1 parent 0afa6360ae
commit cb030573fa
3 files changed
+308 -35

No files matched your search

+53 -2
View File
@@ -917,13 +917,25 @@ namespace jdi
/** /**
* @brief 将两条光标的时间夹取到当前可见时间范围内。 * @brief 将两条光标的时间夹取到当前可见时间范围内。
* *
* ⚠️ 现在**只在拖拽光标时**使用。视图变化(平移/放大/自适应)必须**不**移动光标: * ⚠️ 拖拽光标时使用;**平移**视界后也要用(见 `ClampCursorsAfterPan`)。
* 而**离散**的视图变化(选区放大 / 适应视图 / 按网格时间轴展开)必须**不**移动光标:
* 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。 * 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。
* *
* @param [IN] which 要夹取的光标序号(1 或 2)。 * @param [IN] which 要夹取的光标序号(1 或 2)。
*/ */
void ClampCursorToView(int which); void ClampCursorToView(int which);
/**
* @brief **平移视界**之后把时间光标夹回可见区间(单/双光标都处理)。
*
* ⚠️ 与「离散视图变化不移动光标」并不矛盾:选区放大 / 适应视图 / 按网格时间轴展开
* 仍然不动光标(用户可以先量好再自由探索,光标读数贴在边缘可读、可拖回来);
* 但**平移**是连续手势 —— 尤其在深放大区间,光标一旦被落在屏外就会立刻"跑飞"
* 很远(一屏的平移就是几十甚至上百倍可视跨度),用户会以为光标丢失。
* 因此在平移(中键/左键拖拽、Shift+滚轮、方向键)之后夹一次。
*/
void ClampCursorsAfterPan();
/** /**
* @brief 计算双光标之间的时间差(绝对值)。 * @brief 计算双光标之间的时间差(绝对值)。
* @return 时间差;非双光标模式返回 0。 * @return 时间差;非双光标模式返回 0。
@@ -2274,9 +2286,18 @@ namespace jdi
HBITMAP bitmap = nullptr; /**< 离屏位图。 */ HBITMAP bitmap = nullptr; /**< 离屏位图。 */
HGDIOBJ oldBitmap = nullptr; /**< DC 原选位图。 */ HGDIOBJ oldBitmap = nullptr; /**< DC 原选位图。 */
int width = 0; /**< 位图宽度(含左右各半屏余量)。 */ int width = 0; /**< 位图宽度(含左右各半屏余量)。 */
int height = 0; /**< 位图高度(= 网格高)。 */ int height = 0; /**< 位图高度(含上下各半个网格高的余量)。 */
int marginY = 0; /**< 位图在**可见网格上方**预留的像素行数(纵向预渲染余量)。 */
double wideMin = 0.0; /**< 位图 x=0 对应的时间。 */ double wideMin = 0.0; /**< 位图 x=0 对应的时间。 */
double span = 0.0; /**< 渲染时的视界跨度(决定像素/时间)。 */ double span = 0.0; /**< 渲染时的视界跨度(决定像素/时间)。 */
/**
* @brief 渲染时的**纵向锚点**:该网格的 `yMax`(可见窗口顶行对应的数值)。
*
* 与 `wideMin` 同理,平移(纵向是幅值平移)时只移动**贴图偏移**、不重画像素:
* 需要贴到屏幕顶行的位图行 = `marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`。
*/
double anchorYMax = 0.0;
double pxPerValue = 0.0; /**< 位图纵向像素 / 数值单位(= 可见网格高 / 幅值跨度)。 */
std::uint64_t key = 0; /**< 内容指纹(含跨度、网格几何与外观)。 */ std::uint64_t key = 0; /**< 内容指纹(含跨度、网格几何与外观)。 */
bool valid = false; /**< 是否有可显示的像素。 */ bool valid = false; /**< 是否有可显示的像素。 */
}; };
@@ -2300,8 +2321,27 @@ namespace jdi
*/ */
bool RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, bool RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW,
double wideMin); double wideMin);
/**
* @brief 前台补画各网格的**分界线**(网格顶部那条较重的横线)。
*
* ⚠️ 分界线锚定在网格的**屏幕位置**(`lane.rect.Y`),幅值平移时它**不跟着移动**,
* 因此不能烘进"纵向可平移"的图层里(否则纵向拖动时它会跟着波形跑)。
* 整幅重绘由 `PaintLane` 直接绘制;宽基底快路径(贴图层像素)必须在这里补画一次。
*/
void PaintLaneDividers(gd::Graphics& g) const;
/** @brief 「以当前视界居中」的默认图层映射(位图 `x=0` 对应的时间)。 */ /** @brief 「以当前视界居中」的默认图层映射(位图 `x=0` 对应的时间)。 */
double LayerDefaultWideMin() const; double LayerDefaultWideMin() const;
/** @brief 图层位图的横向余量(左右各半屏 → 可横向平移 ±半屏)。 */
int LayerMarginX() const;
/**
* @brief 图层位图的纵向余量(上下各半个网格高 → 可纵向平移 ±半个网格高)。
*
* ⚠️ 纵向余量让「拖拽平移的纵向分量」(幅值平移)与横向平移一样**只改贴图偏移**,
* 不必每帧重画整块图层 —— 这正是「左右平移流畅、上下平移卡顿」的根因所在。
* 余量越大可连续拖拽的范围越大,但每个横向补条 / 整块重画的代价也按余量线性增长
*(因此取「半个网格高」:一整屏的额外内容已足够,且不拖慢已经流畅的横向平移)。
*/
int LayerMarginY(float laneHeight) const;
/** /**
* @brief 以当前视界为中心重建全部网格图层(同步;跨度变化 / 首帧 / 图层缺失时使用)。 * @brief 以当前视界为中心重建全部网格图层(同步;跨度变化 / 首帧 / 图层缺失时使用)。
@@ -2371,6 +2411,17 @@ namespace jdi
bool m_renderWide = false; /**< 正在渲染信号图层(重入 `PaintLane` 时跳过装饰)。 */ bool m_renderWide = false; /**< 正在渲染信号图层(重入 `PaintLane` 时跳过装饰)。 */
/**
* @brief 渲染信号图层时,横向网格线(幅值刻度)的「参考跨度 / 参考高度」。
*
* ⚠️ **必须是前台可见网格的值**,而不是图层画布的(含纵向余量的)值:
* 图层的幅值刻度必须以该跨度为基准取「整齐步长」,否则画布里的网格线密度与
* 整幅渲染不一致 —— 贴图后拖动时网格线会跳变。步长一致时刻度落在同一个
* 绝对值格点上,于是纵向平移就是纯粹的像素平移(逐像素同源)。
*/
double m_wideTickSpan = 0.0;
float m_wideTickHeight = 0.0f;
/** /**
* @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。 * @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。
* *
+203 -33
View File
@@ -1055,6 +1055,8 @@ namespace jdi
Tab().ViewMin() += delta; Tab().ViewMin() += delta;
Tab().ViewMax() += delta; Tab().ViewMax() += delta;
ClampTimeView(); ClampTimeView();
// 方向键平移同样是平移:把光标留在可见区间内(与拖拽平移一致)。
ClampCursorsAfterPan();
} }
void PlotView::ScrollLanesByFraction(double fraction) void PlotView::ScrollLanesByFraction(double fraction)
@@ -1260,6 +1262,19 @@ namespace jdi
} }
} }
void PlotView::ClampCursorsAfterPan()
{
if (m_tab == nullptr || !Tab().CursorEnabled())
{
return;
}
ClampCursorToView(1);
if (Tab().CursorMode() == PlotCursorMode::DOUBLE)
{
ClampCursorToView(2);
}
}
std::uint32_t PlotView::CursorColor() const std::uint32_t PlotView::CursorColor() const
{ {
// 两条光标统一使用同一样式与颜色,不按序号区分配色。 // 两条光标统一使用同一样式与颜色,不按序号区分配色。
@@ -4067,6 +4082,9 @@ namespace jdi
Tab().ViewMin() += delta; Tab().ViewMin() += delta;
Tab().ViewMax() += delta; Tab().ViewMax() += delta;
ClampTimeView(); ClampTimeView();
// 平移是连续手势:必须把光标留在可见区间内,否则深放大下一屏平移就能把光标
// 甩到视界外很远(用户会认为光标“跑飞”、找不到)。
ClampCursorsAfterPan();
} }
const int laneIndex = m_dragLane; const int laneIndex = m_dragLane;
@@ -4532,7 +4550,16 @@ namespace jdi
m_hoverCursor = labelWhich; m_hoverCursor = labelWhich;
m_activeCursor = labelWhich; m_activeCursor = labelWhich;
m_cursorHover = true; m_cursorHover = true;
m_cursorGrabOffset = x - static_cast<int>(std::lround(CursorDisplayX(labelWhich))); // 抓取偏移 = “手指按在读数标签的哪个位置”。
// ⚠️ 但光标**滞留在视界之外**时读数被钉在网格边缘,此时抓取点与光标真实位置
// 之间没有任何有意义的对应关系:仍然沿用偏移会让首次移动把目标送到视界之外
//(再被夹回边缘),表现为“一拖就跳很远 / 光标钉在边缘不动”。
// 因此这种情形下不带偏移:首次移动就把光标放在鼠标处(自然地拖回来)。
bool offRight = false;
const bool offView = CursorOffViewSide(labelWhich, offRight);
m_cursorGrabOffset =
offView ? 0
: (x - static_cast<int>(std::lround(CursorDisplayX(labelWhich))));
return true; return true;
} }
} }
@@ -5061,6 +5088,8 @@ namespace jdi
Tab().ViewMin() += sweep; Tab().ViewMin() += sweep;
Tab().ViewMax() += sweep; Tab().ViewMax() += sweep;
ClampTimeView(); ClampTimeView();
// Shift+滚轮也是平移:同样不能把光标落在视界之外(与拖拽平移一致)。
ClampCursorsAfterPan();
return true; return true;
} }
@@ -5760,6 +5789,9 @@ namespace jdi
std::vector<double> wideTimeTicks; std::vector<double> wideTimeTicks;
BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks); BuildTicks(wideViewMin, wideViewMax, wideTimeStep, wideTimeTicks);
PaintTimeAxis(g, wideTimeTicks, wideTimeStep); PaintTimeAxis(g, wideTimeTicks, wideTimeStep);
// 网格分界线:图层里**不含**它(它锚定在网格的屏幕位置,会被纵向偏移贴图带走),
// 因此在这里补画一次(与整幅重绘同一套颜色与线宽)。
PaintLaneDividers(g);
// 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。 // 派生预览曲线:基底不含它们(拖参数滑块时基底不变、可直接复用像素)。
for (std::size_t i = 0; i < m_lanes.size(); ++i) for (std::size_t i = 0; i < m_lanes.size(); ++i)
{ {
@@ -7043,8 +7075,12 @@ namespace jdi
std::uint64_t PlotView::LaneLayerHash(int laneIndex) const std::uint64_t PlotView::LaneLayerHash(int laneIndex) const
{ {
// 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值范围 + 本网格各信号的 // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的
// 可见性与外观 + 图例文案(图例不在图层里,但一起计入无妨且更安全)。 // 可见性与外观 + 图例文案(图例不在图层里,但一起计入无妨且更安全)。
//
// ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin`
// 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素),
// 因此纵向拖动不会让图层失效。真正改变内容的是**跨度**(决定像素/数值与刻度步长)。
std::uint64_t hash = 1469598103934665603ull; std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value) const auto mix = [&hash](std::uint64_t value)
{ {
@@ -7063,8 +7099,7 @@ namespace jdi
} }
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)]; const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
mixFloat(visual.rect.Height); mixFloat(visual.rect.Height);
mixFloat(static_cast<float>(visual.yMin)); mixFloat(static_cast<float>(visual.yMax - visual.yMin));
mixFloat(static_cast<float>(visual.yMax));
const std::vector<SignalRef>& signals = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].signals; const std::vector<SignalRef>& signals = Tab().Lanes()[static_cast<std::size_t>(laneIndex)].signals;
mix(signals.size()); mix(signals.size());
for (const SignalRef& ref : signals) for (const SignalRef& ref : signals)
@@ -7085,6 +7120,20 @@ namespace jdi
return hash; return hash;
} }
int PlotView::LayerMarginX() const
{
const int width = static_cast<int>(std::lround(m_plotRect.Width));
return std::max(1, width / 2); // 左右各半屏
}
int PlotView::LayerMarginY(float laneHeight) const
{
// 上下各半个网格高:一整屏的额外内容已足够覆盖实际拖拽范围,
// 又不至于让每个横向补条 / 整块重画按三倍高度放大(横向平移因此不受影响)。
const int half = static_cast<int>(std::lround(static_cast<double>(laneHeight) * 0.5));
return std::max(1, half);
}
double PlotView::LayerDefaultWideMin() const double PlotView::LayerDefaultWideMin() const
{ {
// 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。 // 以当前视界为中心:位图左右各留半个视界宽,因此可向两侧平移 ±半屏。
@@ -7095,8 +7144,7 @@ namespace jdi
return Tab().ViewMin(); return Tab().ViewMin();
} }
const double scale = static_cast<double>(width) / span; const double scale = static_cast<double>(width) / span;
const int margin = std::max(1, width / 2); return Tab().ViewMin() - static_cast<double>(LayerMarginX()) / scale;
return Tab().ViewMin() - static_cast<double>(margin) / scale;
} }
bool PlotView::LayersStructurallyReady() const bool PlotView::LayersStructurallyReady() const
@@ -7113,8 +7161,7 @@ namespace jdi
return false; return false;
} }
const int width = static_cast<int>(std::lround(plotWidth)); const int width = static_cast<int>(std::lround(plotWidth));
const int margin = std::max(1, width / 2); const int bitmapWidth = width + 2 * LayerMarginX();
const int bitmapWidth = width + 2 * margin;
for (std::size_t index = 0; index < m_layerFront.size(); ++index) for (std::size_t index = 0; index < m_layerFront.size(); ++index)
{ {
const LaneLayer& layer = m_layerFront[index]; const LaneLayer& layer = m_layerFront[index];
@@ -7127,9 +7174,10 @@ namespace jdi
{ {
return false; return false;
} }
// 高度也要一致:网格高度变化会让贴图纵向错位。 // 高度也要一致:网格高度变化会让贴图纵向错位(含上下预渲染余量)。
const float laneHeight = m_lanes[index].rect.Height;
const int bitmapHeight = const int bitmapHeight =
static_cast<int>(std::ceil(m_lanes[index].rect.Height)); static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (layer.height != bitmapHeight) if (layer.height != bitmapHeight)
{ {
return false; return false;
@@ -7157,9 +7205,9 @@ namespace jdi
return false; return false;
} }
const int width = static_cast<int>(std::lround(plotWidth)); const int width = static_cast<int>(std::lround(plotWidth));
const int margin = std::max(1, width / 2); const int bitmapWidth = width + 2 * LayerMarginX();
const int bitmapWidth = width + 2 * margin; const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height;
const int bitmapHeight = static_cast<int>(std::ceil(m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height)); const int bitmapHeight = static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth ||
layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12)
{ {
@@ -7259,16 +7307,21 @@ namespace jdi
} }
const int width = static_cast<int>(std::lround(plotWidth)); const int width = static_cast<int>(std::lround(plotWidth));
const int margin = std::max(1, width / 2); // 左右各半屏 → 可平移 ±半屏 const int margin = LayerMarginX(); // 左右各半屏 → 可平移 ±半屏
const int bitmapWidth = width + 2 * margin; const int bitmapWidth = width + 2 * margin;
const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height; const float laneHeight = m_lanes[static_cast<std::size_t>(laneIndex)].rect.Height;
const int bitmapHeight = static_cast<int>(std::ceil(laneHeight)); const int lanePixelHeight = static_cast<int>(std::ceil(laneHeight));
const int marginY = LayerMarginY(laneHeight); // 上下各半个网格高 → 幅值可平移 ±半个网格高
const int bitmapHeight = lanePixelHeight + 2 * marginY;
if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight)) if (bitmapHeight <= 0 || !EnsureLaneLayer(layer, bitmapWidth, bitmapHeight))
{ {
return false; return false;
} }
const double scale = static_cast<double>(width) / span; // 像素 / 时间 const double scale = static_cast<double>(width) / span; // 像素 / 时间
const double laneSpan = m_lanes[static_cast<std::size_t>(laneIndex)].yMax -
m_lanes[static_cast<std::size_t>(laneIndex)].yMin;
const double pxPerValue = (laneSpan > 0.0) ? static_cast<double>(laneHeight) / laneSpan : 0.0;
if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth) if (stripW <= 0 || stripX < 0 || stripX + stripW > bitmapWidth)
{ {
@@ -7285,17 +7338,25 @@ namespace jdi
const bool savedFastFrame = m_fastFrame; const bool savedFastFrame = m_fastFrame;
const bool savedRenderWide = m_renderWide; const bool savedRenderWide = m_renderWide;
const float savedScroll = Tab().LaneScroll(); const float savedScroll = Tab().LaneScroll();
const double savedWideTickSpan = m_wideTickSpan;
const float savedWideTickHeight = m_wideTickHeight;
m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth), laneHeight); m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth),
static_cast<float>(bitmapHeight));
// 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面; // 只把 `m_area` 限为待渲染的那一段 x 区间,使 GDI/GDI+ 的裁剪只落在它上面;
// x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。 // x 坐标仍用**整条宽窗口**的映射(`m_plotRect` 为完整宽图),因此网格线位置与整块图层一致。
m_area = gd::RectF(static_cast<float>(stripX), 0.0f, static_cast<float>(stripW), laneHeight); m_area = gd::RectF(static_cast<float>(stripX), 0.0f, static_cast<float>(stripW),
static_cast<float>(bitmapHeight));
Tab().ViewMin() = wideMin; Tab().ViewMin() = wideMin;
Tab().ViewMax() = wideMin + static_cast<double>(bitmapWidth) / scale; Tab().ViewMax() = wideMin + static_cast<double>(bitmapWidth) / scale;
m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰 m_renderWide = true; // 图层只含基底:跳过派生曲线与装饰
// 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。 // 图层必须与"同一视界的整幅渲染"**逐像素同源**:强制全质量抗锯齿(不受拖拽快速帧影响)。
m_fastFrame = false; m_fastFrame = false;
// 幅值刻度的参考跨度 / 参考高度取**前台可见网格**的值(而不是含上下余量的画布值):
// 这样图层里的横向网格线步长与前台一致,纵向平移才是纯粹的像素平移。
m_wideTickSpan = laneSpan;
m_wideTickHeight = laneHeight;
BuildLaneVisuals(); BuildLaneVisuals();
// 沿用**当前视界**的 Y 轴范围,否则幅值映射与前台不一致。 // 沿用**当前视界**的 Y 轴范围,否则幅值映射与前台不一致。
for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i) for (std::size_t i = 0; i < m_lanes.size() && i < savedLanes.size(); ++i)
@@ -7303,9 +7364,23 @@ namespace jdi
m_lanes[i].yMin = savedLanes[i].yMin; m_lanes[i].yMin = savedLanes[i].yMin;
m_lanes[i].yMax = savedLanes[i].yMax; m_lanes[i].yMax = savedLanes[i].yMax;
} }
// 该网格在本图层中的位置:横向铺满整条宽图,纵向从 0 开始。 // 该网格在本图层中的位置:横向铺满整条宽图,纵向铺满整张画布。
// 画布 = 「可见网格 + 上下各 `marginY` 行余量」。**上下扩展量按"像素行"精确换算**
//(而不是上下各扩展固定的数值量):位图高度是整数(`ceil(laneHeight) + 2*marginY`),
// 只有当「扩展后总跨度 / 画布行数」严格等于前台的 px/数值 时,可见网格在画布里的映射
// 才与前台整幅渲染**逐像素同源**(否则会差一个 <1px 的纵向缩放误差,
// 让拖拽期间的像素残差明显变大)。
if (pxPerValue > 0.0)
{
const double topExtend = static_cast<double>(marginY) / pxPerValue;
const double bottomRows =
static_cast<double>(bitmapHeight - marginY) - static_cast<double>(laneHeight);
const double bottomExtend = std::max(0.0, bottomRows) / pxPerValue;
m_lanes[static_cast<std::size_t>(laneIndex)].yMin -= bottomExtend;
m_lanes[static_cast<std::size_t>(laneIndex)].yMax += topExtend;
}
m_lanes[static_cast<std::size_t>(laneIndex)].rect = m_lanes[static_cast<std::size_t>(laneIndex)].rect =
gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth), laneHeight); gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth), static_cast<float>(bitmapHeight));
{ {
gd::Graphics g(layer.dc); gd::Graphics g(layer.dc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias); g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
@@ -7324,6 +7399,8 @@ namespace jdi
} }
m_fastFrame = savedFastFrame; m_fastFrame = savedFastFrame;
m_renderWide = savedRenderWide; m_renderWide = savedRenderWide;
m_wideTickSpan = savedWideTickSpan;
m_wideTickHeight = savedWideTickHeight;
// 恢复布局 / 视图 / 网格。 // 恢复布局 / 视图 / 网格。
// ⚠️ **必须调用 `BuildLaneVisuals()`**(而不是只把 `m_lanes` 拷回去):它会按正常布局 // ⚠️ **必须调用 `BuildLaneVisuals()`**(而不是只把 `m_lanes` 拷回去):它会按正常布局
@@ -7350,6 +7427,11 @@ namespace jdi
layer.wideMin = wideMin; layer.wideMin = wideMin;
layer.span = span; layer.span = span;
layer.marginY = marginY;
// 纵向锚点 = 渲染时该网格的 `yMax`(可见窗口顶行对应的数值)。
// 贴图时按 `(anchorYMax - 当前 yMax) * pxPerValue` 得到纵向偏移像素数。
layer.anchorYMax = savedLanes[static_cast<std::size_t>(laneIndex)].yMax;
layer.pxPerValue = pxPerValue;
layer.key = LaneLayerKey(laneIndex); layer.key = LaneLayerKey(laneIndex);
layer.valid = true; layer.valid = true;
m_layerRenderCount.fetch_add(1, std::memory_order_relaxed); m_layerRenderCount.fetch_add(1, std::memory_order_relaxed);
@@ -7394,7 +7476,7 @@ namespace jdi
return; return;
} }
const int width = static_cast<int>(std::lround(plotWidth)); const int width = static_cast<int>(std::lround(plotWidth));
const int margin = std::max(1, width / 2); const int margin = LayerMarginX();
const int bitmapWidth = width + 2 * margin; const int bitmapWidth = width + 2 * margin;
const double scale = static_cast<double>(width) / span; const double scale = static_cast<double>(width) / span;
@@ -7419,7 +7501,9 @@ namespace jdi
const int laneIndex = static_cast<int>(index); const int laneIndex = static_cast<int>(index);
// 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。
const int bitmapHeight = static_cast<int>(std::ceil(m_lanes[index].rect.Height)); const float laneHeight = m_lanes[index].rect.Height;
const int bitmapHeight =
static_cast<int>(std::ceil(laneHeight)) + 2 * LayerMarginY(laneHeight);
if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth ||
layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12)
{ {
@@ -7431,12 +7515,23 @@ namespace jdi
const double desired = (Tab().ViewMin() - layer.wideMin) * scale; const double desired = (Tab().ViewMin() - layer.wideMin) * scale;
const int shift = static_cast<int>(std::llround(desired - static_cast<double>(margin))); const int shift = static_cast<int>(std::llround(desired - static_cast<double>(margin)));
const bool tooFar = (std::abs(shift) >= bitmapWidth - 4); const bool tooFar = (std::abs(shift) >= bitmapWidth - 4);
// 内容(外观 / 网格几何 / 幅值 / 可见性…)与当前状态不一致。 // 内容(外观 / 网格几何 / 幅值**跨度** / 可见性…)与当前状态不一致。
const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); const bool contentStale = (layer.key != LaneLayerKey(laneIndex));
// 纵向:幅值**平移**不改内容,只改贴图偏移;但偏移超出画布余量时必须重画(重新居中)。
if (contentStale || tooFar) int vOffset = 0;
if (layer.pxPerValue > 0.0)
{ {
// 登记待重画(本帧不重画),但仍按现有像素平移,避免网格"卡在原地"。 vOffset = static_cast<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
const int topRow = layer.marginY + vOffset;
const bool verticalTooFar = (topRow < 0) ||
(topRow + static_cast<int>(std::ceil(laneHeight)) > layer.height);
if (contentStale || tooFar || verticalTooFar)
{
// 登记待重画(本帧不重画,改由 `RenderPendingLaneRebuilds` 在**贴图之前**补画),
// 但仍按现有像素平移,避免网格"卡在原地"。
QueueLaneRebuild(laneIndex); QueueLaneRebuild(laneIndex);
if (!tooFar && shift != 0) if (!tooFar && shift != 0)
{ {
@@ -7491,7 +7586,7 @@ namespace jdi
return false; return false;
} }
const int width = static_cast<int>(std::lround(plotWidth)); const int width = static_cast<int>(std::lround(plotWidth));
const int margin = std::max(1, width / 2); const int margin = LayerMarginX();
const int bitmapWidth = width + 2 * margin; const int bitmapWidth = width + 2 * margin;
const double scale = static_cast<double>(width) / span; const double scale = static_cast<double>(width) / span;
@@ -7526,8 +7621,37 @@ namespace jdi
} }
const int destX = static_cast<int>(std::lround(m_plotRect.X)); const int destX = static_cast<int>(std::lround(m_plotRect.X));
const int destY = static_cast<int>(std::lround(top)); const int destY = static_cast<int>(std::lround(top));
const int srcY = static_cast<int>(std::lround(top - lane.Y)); int height = static_cast<int>(std::lround(bottom - top));
const int height = static_cast<int>(std::lround(bottom - top)); if (height <= 0)
{
continue;
}
// ⚠️ **纵向偏移**:图层带「上下各半个网格高」的预渲染余量,幅值平移只改这里的
// 源行偏移(`anchorYMax` 与当前 `yMax` 之差换算成像素),不必重画图层 ——
// 这就是"纵向拖动与横向拖动同样是 O(1) 贴图"的关键。
int srcY = 0;
{
int vOffset = 0;
if (layer.pxPerValue > 0.0)
{
vOffset = static_cast<int>(
std::llround((layer.anchorYMax - m_lanes[index].yMax) * layer.pxPerValue));
}
srcY = static_cast<int>(std::lround(top - lane.Y)) + layer.marginY + vOffset;
}
// 超出画布的上下两端只贴**有像素的那一段**(其余由底色填充,本帧的预算内重画会补齐)。
int destTopOffset = 0;
if (srcY < 0)
{
destTopOffset = -srcY;
height -= destTopOffset;
srcY = 0;
}
if (srcY + height > layer.height)
{
height = layer.height - srcY;
}
if (height <= 0) if (height <= 0)
{ {
continue; continue;
@@ -7554,7 +7678,8 @@ namespace jdi
{ {
continue; // 完全没有可用像素(该网格待重画) continue; // 完全没有可用像素(该网格待重画)
} }
BitBlt(target, destX + destOffset, destY, blitWidth, height, layer.dc, srcX, srcY, SRCCOPY); BitBlt(target, destX + destOffset, destY + destTopOffset, blitWidth, height, layer.dc, srcX,
srcY, SRCCOPY);
any = true; any = true;
} }
g.ReleaseHDC(target); g.ReleaseHDC(target);
@@ -7705,7 +7830,21 @@ namespace jdi
int yTickTarget = static_cast<int>(std::lround(lane.rect.Height / (kYTickSpacing * m_scale))); int yTickTarget = static_cast<int>(std::lround(lane.rect.Height / (kYTickSpacing * m_scale)));
yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax)); yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax));
const double yStep = NiceStep(lane.yMax - lane.yMin, yTickTarget); // ⚠️ 渲染「信号图层」时,幅值刻度必须以**前台可见网格**的跨度/高度为基准:
// 图层画布比可见网格高(上下各留预渲染余量),若按画布高度与扩展后的跨度取刻度,
// 图里的网格线密度就与整幅渲染不一致 —— 贴图后拖动时网格线会跳变。
// 用可见跨度取步长 + 按扩展范围铺满整张画布 ⇒ 刻度落在同一批绝对值格点上,
// 于是"纵向平移 = 纯粹的像素平移"(逐像素同源)。
const double tickSpan = (m_renderWide && m_wideTickSpan > 0.0)
? m_wideTickSpan
: (lane.yMax - lane.yMin);
if (m_renderWide && m_wideTickHeight > 0.0f)
{
yTickTarget = static_cast<int>(
std::lround(m_wideTickHeight / (kYTickSpacing * m_scale)));
yTickTarget = std::max(kYTickTargetMin, std::min(yTickTarget, kYTickTargetMax));
}
const double yStep = NiceStep(tickSpan, yTickTarget);
std::vector<double> yTicks; std::vector<double> yTicks;
BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); BuildTicks(lane.yMin, lane.yMax, yStep, yTicks);
@@ -7956,8 +8095,11 @@ namespace jdi
} }
// 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。 // 网格分界线:网格之间不再留间隔,改用较重的横线明确分隔相邻网格。
// ⚠️ **渲染信号图层时跳过**:分界线锚定在网格的**屏幕位置**(`lane.rect.Y`),
// 而图层会被纵向偏移贴图(幅值平移)—— 若烘进图层,纵向拖动时它会跟着波形一起跑。
// 因此它在整幅重绘里由这里绘制,宽基底快路径则由 `PaintLaneDividers` 补画一次。
const float dividerY = lane.rect.Y; const float dividerY = lane.rect.Y;
if (!m_renderDecorOnly && dividerY >= m_plotRect.Y - 1.0f && if (!m_renderDecorOnly && !m_renderWide && dividerY >= m_plotRect.Y - 1.0f &&
dividerY <= m_plotRect.Y + m_plotRect.Height) dividerY <= m_plotRect.Y + m_plotRect.Height)
{ {
const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY}; const float dividerSegments[4] = {lane.rect.X, dividerY, lane.rect.X + lane.rect.Width, dividerY};
@@ -8068,6 +8210,30 @@ namespace jdi
g.Restore(saveState); g.Restore(saveState);
} }
void PlotView::PaintLaneDividers(gd::Graphics& g) const
{
if (m_theme == nullptr)
{
return;
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const gd::RectF& rect = m_lanes[index].rect;
const float dividerY = rect.Y;
if (dividerY < m_plotRect.Y - 1.0f || dividerY > m_plotRect.Y + m_plotRect.Height)
{
continue;
}
const float segments[4] = {rect.X, dividerY, rect.X + rect.Width, dividerY};
const std::uint32_t dividerColor =
((index == 0) ? m_theme->axisLine : m_theme->borderStrong) | 0xFF000000u;
// GDI 批绘不受 GDI+ 裁剪约束 → 显式给出只覆盖该分界线附近的裁剪矩形。
const gd::RectF clip(m_area.X, dividerY - 4.0f * m_scale, m_area.Width, 8.0f * m_scale);
UiDrawSolidLines(g, clip, segments, 1, dividerColor,
(index == 0) ? 2 : static_cast<int>(kLaneDividerWidth));
}
}
SignalRef PlotView::LegendAtPoint(int x, int y) const SignalRef PlotView::LegendAtPoint(int x, int y) const
{ {
const float fx = static_cast<float>(x); const float fx = static_cast<float>(x);
@@ -9910,8 +10076,12 @@ namespace jdi
for (const LaneVisual& visual : m_lanes) for (const LaneVisual& visual : m_lanes)
{ {
mixRect(visual.rect); mixRect(visual.rect);
mixFloat(static_cast<float>(visual.yMin)); // ⚠️ **幅值范围(yMin/yMax)刻意不入面板指纹**:面板内容(标题、条目、滑块、
mixFloat(static_cast<float>(visual.yMax)); // 参数与边沿统计)都不依赖网格的幅值区间;而「拖拽平移的纵向分量」会每帧小幅
// 改动它 —— 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍
//(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的
// "左右流畅、上下/斜向卡顿")。代价只是面板不透明底(95.7%)背后那 4%
// 透出的底层像素可能旧一帧,与既有取舍一致(实测最大色差 ≤ 29 级)。
} }
if (m_tab != nullptr) if (m_tab != nullptr)
+52
View File
@@ -513,6 +513,7 @@ Documents/ 项目文档(含本文件)
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住键拖拽平移时波形质量下降 / 断线 / 周期性卡顿"与"浮动面板互相覆盖时交互错位"**,并把弱峰增强改为**逐信号参数**。① 平移:`RollWideBuffer` 不再整块重画(只平移+补细条,内容过期/锚点过远只入队),新增 `RenderPendingLaneRebuilds(budget)`(**每帧最多重画 1 个网格**,其余下帧)+ `BlitWideBuffer` **局部贴图**(图层没覆盖的部分留空,不再整块跳过);"结算"整幅精确帧**仅限非拖拽且全部图层就绪**(消除拖拽中每两帧的整幅重绘/质量振荡)。实测 `VERY_BIG_DATA` 5 网格深放大平移:中位 **6.2ms**、max 7.6ms(无尖峰);按住键且视界停在边界时 **0.29ms/帧**。② 弱峰增强:`WeakPeakItem` 改为携带 `levelPercent(0–200)+iterations`,面板改为与均值滤波同构的**逐信号两个输入框**(删除网格级两滑块),哈希/快照/构建/预览全部改为逐信号。③ 浮动面板:新增 `m_panelOrder` 层序,**命中从最上层往下**、点击 `BringPanelToFront`、新面板置顶;修复"上层 ✕ / 标题栏被下层参数框抢走"。离屏测试 **388/388 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住键拖拽平移时波形质量下降 / 断线 / 周期性卡顿"与"浮动面板互相覆盖时交互错位"**,并把弱峰增强改为**逐信号参数**。① 平移:`RollWideBuffer` 不再整块重画(只平移+补细条,内容过期/锚点过远只入队),新增 `RenderPendingLaneRebuilds(budget)`(**每帧最多重画 1 个网格**,其余下帧)+ `BlitWideBuffer` **局部贴图**(图层没覆盖的部分留空,不再整块跳过);"结算"整幅精确帧**仅限非拖拽且全部图层就绪**(消除拖拽中每两帧的整幅重绘/质量振荡)。实测 `VERY_BIG_DATA` 5 网格深放大平移:中位 **6.2ms**、max 7.6ms(无尖峰);按住键且视界停在边界时 **0.29ms/帧**。② 弱峰增强:`WeakPeakItem` 改为携带 `levelPercent(0–200)+iterations`,面板改为与均值滤波同构的**逐信号两个输入框**(删除网格级两滑块),哈希/快照/构建/预览全部改为逐信号。③ 浮动面板:新增 `m_panelOrder` 层序,**命中从最上层往下**、点击 `BringPanelToFront`、新面板置顶;修复"上层 ✕ / 标题栏被下层参数框抢走"。离屏测试 **388/388 通过**。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | 弱峰增强的参数输入改回**逐信号滑块**(每信号两条轨道:增强等级 0–200% / 迭代次数 N),并让「迭代次数 N」**上限可就地编辑**:点击滑块右侧数值 → 整行变成输入框(`EditFilter::BYTE` 只收数字、空内容显示灰提示当前上限、`0`/超限/空 保持原值、下调后当前迭代次数自动被夹到新上限);上限进 `PanelSlotKey` 但不进 `SceneContentHash`(不影响波形重画)。离屏测试 **409/409 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | 弱峰增强的参数输入改回**逐信号滑块**(每信号两条轨道:增强等级 0–200% / 迭代次数 N),并让「迭代次数 N」**上限可就地编辑**:点击滑块右侧数值 → 整行变成输入框(`EditFilter::BYTE` 只收数字、空内容显示灰提示当前上限、`0`/超限/空 保持原值、下调后当前迭代次数自动被夹到新上限);上限进 `PanelSlotKey` 但不进 `SceneContentHash`(不影响波形重画)。离屏测试 **409/409 通过**。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"斜向/不规则拖拽自由移动视界时的卡顿、掉帧与波形错误绘制"、"预览曲线不如普通信号细腻(无抗锯齿)"、"拖动参数滑块时预览曲线跳帧闪烁"**。① 根因:拖拽平移的**纵向分量**会逐帧小改被拖网格的 `yMin/yMax`,而**网格幅值范围同时参与「宽基底可用性」几何指纹与每个网格的图层键**,于是每帧都退化成整幅重绘(`m_fastFrame` 关抗锯齿)+ 5 个网格全部入队(一帧只补 1 个)→ 质量下降、卡顿、图层相互不一致(断线)。修法:`SceneContentHash` 新增 `includeLaneRange`(`SceneGeometryHash()` = `false,true,false`,幅值不进"能不能走快路径"的判定),幅值改由**逐网格** `LaneLayerHash(laneIndex)`(`LaneLayerKey` 的基)承担 → 只让真正变化的那个网格失效;并把 `RenderPendingLaneRebuilds(1)` **移到 `BlitWideBuffer` 之前**(本帧补好的网格本帧就可见)。② 预览曲线抗锯齿不再因交互(`m_fastFrame`)关闭(仅保留"陡峭段过多"这一条形同无声的逃生口)。③ `AcquireNewestCurve` 改为**只按「信号 + 曲线种类」匹配**(参数无关,优先挑覆盖所需区间的条目)→ 拖参数滑块时用上一帧的曲线顶替,后台算好再无缝替换,**不再闪烁/消失**。离屏验证:斜向拖拽 40/40 帧走宽基底快路径(中位 9.2ms,无尖峰),参数连改 10 次 **0 个空白帧**,抗锯齿回归断言(快帧混淆边缘像素 8149/8404);`uitest` **420/420 通过**、`panperf` PASS、`layercheck` 保持"0 次视界覆盖 / 平移对齐 ≤1px"。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"斜向/不规则拖拽自由移动视界时的卡顿、掉帧与波形错误绘制"、"预览曲线不如普通信号细腻(无抗锯齿)"、"拖动参数滑块时预览曲线跳帧闪烁"**。① 根因:拖拽平移的**纵向分量**会逐帧小改被拖网格的 `yMin/yMax`,而**网格幅值范围同时参与「宽基底可用性」几何指纹与每个网格的图层键**,于是每帧都退化成整幅重绘(`m_fastFrame` 关抗锯齿)+ 5 个网格全部入队(一帧只补 1 个)→ 质量下降、卡顿、图层相互不一致(断线)。修法:`SceneContentHash` 新增 `includeLaneRange`(`SceneGeometryHash()` = `false,true,false`,幅值不进"能不能走快路径"的判定),幅值改由**逐网格** `LaneLayerHash(laneIndex)`(`LaneLayerKey` 的基)承担 → 只让真正变化的那个网格失效;并把 `RenderPendingLaneRebuilds(1)` **移到 `BlitWideBuffer` 之前**(本帧补好的网格本帧就可见)。② 预览曲线抗锯齿不再因交互(`m_fastFrame`)关闭(仅保留"陡峭段过多"这一条形同无声的逃生口)。③ `AcquireNewestCurve` 改为**只按「信号 + 曲线种类」匹配**(参数无关,优先挑覆盖所需区间的条目)→ 拖参数滑块时用上一帧的曲线顶替,后台算好再无缝替换,**不再闪烁/消失**。离屏验证:斜向拖拽 40/40 帧走宽基底快路径(中位 9.2ms,无尖峰),参数连改 10 次 **0 个空白帧**,抗锯齿回归断言(快帧混淆边缘像素 8149/8404);`uitest` **420/420 通过**、`panperf` PASS、`layercheck` 保持"0 次视界覆盖 / 平移对齐 ≤1px"。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"深放大下左右平移流畅、上下/斜向平移卡顿"(用户猜测:上下没有预渲染机制——已证实)与"深放大时拖拽光标跑飞"**。① 量化复现:`panperf` 新增纯竖直拖拽 + 逐帧统计**图层重画次数**,并在 5 网格全开 4 类悬浮面板(20 个)后对比 —— 竖直拖拽中位 **166.6ms/帧**、水平 **37.7ms/帧**(4.4 倍差距,用户报告的"上下卡顿"正是它)。② 主因是**面板像素图层被幅值范围击穿**:`PanelGlobalKey()` 计入 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层 20 个面板每帧重画(去掉后 166.6→**24.8ms**);次因是**图层没有纵向预渲染余量**(幅值一变就整块重画,实测 40 帧里 40 次 → 修复后 **1 次**)。③ 修法:`LaneLayer` 增加 `marginY/anchorYMax/pxPerValue`,幅值范围按**像素行**精确扩展(上下各半个网格高)、贴图时按 `(anchorYMax - 当前 yMax) * pxPerValue` 取纵向偏移,图层指纹只计幅值**跨度**不计绝对范围;幅值刻度改以**前台可见网格**的跨度/高度取 `NiceStep`(保证与整幅渲染逐像素同源);网格分界线移出图层(改由宽基底路径的 `PaintLaneDividers()` 补画,否则纵向偏移会把它带走)。④ 光标:深放大下平移会把时间光标甩到视界外很远(像"跑飞/丢失")—— 新增 `ClampCursorsAfterPan()`,在**平移**(中键/左键拖拽、Shift+滚轮、方向键)之后夹取;**离散**的视图变化(选区放大/适应视图/按网格时间轴展开)仍然不移动光标;抓取被钉在边缘的光标读数时不再沿用抓取偏移(否则首帧会跳到视界外再被夹回边缘)。验证:`uitest` **432/432**(新增 `[AI]`:贴边读数抓取后光标落在鼠标处误差 0.0px、中键平移 1.5 屏 0 帧出界且跟手)、`panperf` 竖直 3.8~5.0ms(40 帧仅 1 次图层重画)、水平 6.5ms 持平、`layercheck` 0 视界覆盖、新增探针 `.preflight/cursorcheck` 30/30。 |
--- ---
@@ -2627,3 +2628,54 @@ Documents/ 项目文档(含本文件)
3. **待重画网格要在贴图之前补**,否则用户看到的是"比鼠标慢半拍"的旧像素。 3. **待重画网格要在贴图之前补**,否则用户看到的是"比鼠标慢半拍"的旧像素。
4. **异步发布的派生曲线必须有"参数无关"的兜底**(按信号 + 种类取最新),否则拖参数滑块必然闪烁。 4. **异步发布的派生曲线必须有"参数无关"的兜底**(按信号 + 种类取最新),否则拖参数滑块必然闪烁。
5. **交互优化只能降分辨率(顶点数),不能关抗锯齿**:基底图层永远是全质量抗锯齿,叠加层一旦关了就会"看着更粗糙"。 5. **交互优化只能降分辨率(顶点数),不能关抗锯齿**:基底图层永远是全质量抗锯齿,叠加层一旦关了就会"看着更粗糙"。
---
## 本轮(2026-10-12,起源设备):上下/斜向平移卡顿(纵向预渲染)+ 深放大拖拽光标跑飞
用户报告:以 `VERY_BIG_DATA.CSV` 为例,**Y 轴自适应 + 选区放大**到主时间轴约 1~2ms 一大格后,中键拖拽移动视界时「左右很流畅,但上下或斜向会卡顿」,并猜测「上下移动时没有像左右那样的预渲染缓存」。另外「小缩放区间拖拽光标跑飞」的旧缺陷复现。
### A. 先验证猜测(量化,而不是凭感觉)
`.preflight/panperf` 新增:**纯竖直拖拽**(只改被拖网格幅值、视界不动)、逐帧统计 `BackgroundLayerRenderCount()` 增量,以及「5 网格全开 4 类悬浮面板(20 个)」后的水平/竖直对比。结果:
| 场景 | 水平拖拽 | 竖直拖拽 | 每次竖直帧的图层重画次数 |
| --- | --- | --- | --- |
| 无面板 | 6.29ms/帧 | 5.26ms/帧 | **1 次/帧**(水平:5 个横向补条/帧) |
| 20 个悬浮面板 | 37.7ms/帧 | **166.6ms/帧** | 同上 |
结论:猜测**部分成立**——竖直方向确实没有预渲染(每帧整块重画该网格图层),但**主因**是另一处:浮动面板的**像素图层全局键**里含 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层面板缓存失效 → 20 个面板每帧全部重画(≈130ms/帧)。水平平移不改幅值,所以只有竖直/斜向才触发 —— 这正好解释了用户观察到的「不对称」。
### B. 图层改为**二维预渲染**(横向 ±半屏 / 纵向 ±半个网格高)
- `LaneLayer` 新增 `marginY`(可见网格上方的余量行数)、`anchorYMax`(渲染时的 `yMax`)、 `pxPerValue`(像素/数值单位);位图高度 = `ceil(可见网格高) + 2*marginY`。
- 渲染时把该网格幅值范围按**像素行**精确扩展(上扩 `marginY/pxPerValue`,下扩
`(bitmapHeight - marginY - laneHeight)/pxPerValue`)。⚠️ 必须按行数反推数值扩展量:
只有这样「扩展跨度 / 画布行数」才严格等于前台的 px/数值,可见网格在画布中的映射才与整幅渲染**逐像素同源**。
早先按「上下各扩同样的数值量」时存在 <1px 的纵向缩放误差,水平平移的像素残差实测从 77k 涨到 105k,改对后回到 77k。
- 图层里的**幅值刻度必须以前台可见网格的跨度/高度为基准**(`m_wideTickSpan` / `m_wideTickHeight`):画布更高、范围更宽,若按画布值取 `NiceStep`,网格线密度就与前台不一致(贴图后拖动网格线跳变)。
步长一致 ⇒ 刻度落在同一批绝对值格点上 ⇒ 纵向平移就是纯像素平移。
- 贴图:`srcY = round(top - lane.Y) + marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`;超出画布上下端时只贴**有像素的那一段**。`RollWideBuffer` 里只有 `|偏移|` 超出余量时才排队重画(重画即重新居中)。
- 图层指纹:`LaneLayerHash` 只计幅值**跨度**(`yMax - yMin`),**不计绝对范围**(绝对范围是贴图偏移的来源)。
- ⚠️ **网格分界线必须移出图层**:它锚定在网格的**屏幕位置**,而图层会被纵向偏移贴图;若烘进图层,竖直拖动时它会跟着波形跑。现在整幅重绘由 `PaintLane` 画,宽基底快路径由 `PaintLaneDividers()` 补画一次。
### C. 面板像素图层不再被幅值范围击穿
- `PanelGlobalKey()` 去掉 `visual.yMin/yMax`:面板内容(标题、条目、滑块、参数、边沿统计)都与幅值区间无关。
- 代价:面板底是不透明度 95.7% 的圆角矩形,缓存像素里"它下面的场景"可能旧一帧 → 透出 **4.3%**,色差**有上界**(≤ 0.043×255 ≈ 11 级),与既有的「旧一帧」取舍一致,[AF] 的容差 96 覆盖得住。
### D. 深放大下拖拽光标「跑飞」
- 历史:`388d3b6`/`67f659a` 里「视图变化后夹光标」被 `eaf5b9c` 刻意删除(改为「光标可滞留在视界外 + 贴边读数 + 方向三角」)。但深放大下一屏平移就是几十倍可视跨度,光标瞬间被甩到视界外很远 —— 用户看到的就是「拖拽(视界)时光标跑飞」。
- 修法(区分**连续手势**与**离散视图变化**):
* 新增 `ClampCursorsAfterPan()`,在**平移**路径调用:PAN 拖拽(中键/左键)、Shift+滚轮、方向键(`PanTimeByFraction`)。
* ⚠️ **离散**视图变化(选区放大 / 适应视图 / 按网格时间轴展开)**仍然不移动光标** —— 用户可以先量好再自由探索, 贴边读数可读、可拖回来。不要在这几处加夹取。
* 抓取**被钉在边缘**的光标读数时不带抓取偏移(`CursorOffViewSide` 为真 → `m_cursorGrabOffset = 0`): 否则首次移动会把目标送到视界外再被夹回边缘(表现为"一拖就跳很远 / 光标钉在边缘不动")。
### E. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `uitest` **432/432**:`[AH]` 更新为"幅值**平移**不再让图层失效(只改贴图偏移)、幅值**跨度**变化仍然失效",并新增「纯竖直拖拽 8 帧 **0 次图层重画**」;新增 `[AI]`:贴边读数抓取后光标落在鼠标处(误差 **0.0px**)、中键平移 1.5 屏期间 **0 帧出界**且光标确实跟手。
- `panperf`(VERY_BIG_DATA、5 网格、≈1~2ms 一大格):竖直拖拽中位 **3.8~5.0ms**、40 帧仅 **1** 次图层重画;水平 5.7~6.5ms(与修复前持平);对角 11~13ms(略增:补条要按 2 倍画布高渲染);整幅质量对比残差与修复前同量级(水平 77k~81k ↔ 修复后 77k~81k;对角仅被拖动的那一个网格增加,属同一类 ≤0.5px 整数像素残差)。
- 20 个悬浮面板时:竖直 **166.6 → 24.8ms/帧**,水平 37.7 → 38.8ms(持平)——"上下比左右卡"的不对称消失。
- `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异;新增探针 `.preflight/cursorcheck`(真文件 + 真缩放)**30/30**(单/双光标拖拽、贴边读数抓取、中键/键盘/滚轮平移)。
- `make clean && make all` 零警告。