深放大中键拖拽卡顿根因修复(GDI+ 抗锯齿"满幅锯齿")+ 横向补条裁剪 + 并行网格图层(B/C)

用户报告:VERY_BIG_DATA.CSV 只显示编码器A相、选区放大到 [10.2719,10.2831]s(≈1.12ms 一大格)
后,中键拖拽视界非常卡顿。要求做 B(拆分宽基底路径定位瓶颈)与 C(解除"不允许多线程"约束
并实现并行网格渲染)。

B —— 宽基底路径耗时拆解(.preflight/perf/dragperf mode 1):
  该视界下 RollWideBuffer(补条重绘)13.08ms,BlitWideBuffer 0.50 / PaintTimeAxis 0.78 /
  PaintLaneDividers 0.04 / PaintLaneDerivedOverlays 0.00 / CaptureDecorCache 0.03ms
  —— 瓶颈全在"补条重绘"。

横向补条裁剪(RollWideBuffer 加速):
  补条只覆盖图层位图一条窄竖带,但折线原本按整条宽窗口(2x 视界)构建/描边整段顶点。
  新增 m_stripRenderActive/m_stripX0/m_stripX1:横向补条时把折线采样区间裁剪到条带对应 x 范围
  (两侧留 max(4, lineWidth+3) 像素余量),并旁路折线缓存(补条只构建片段,写缓存会污染整段复用)。
  裁剪只对"逐点描边"路径生效,且必须在"包络/描边"判定之后(否则 visibleCount 变化会改路径判定)。
  实测 RollWideBuffer 13.08ms -> 1.73ms;拖拽整帧 13~24ms -> ~3ms;补条 vs 整块逐像素 0 差异。

真正的病态根因:GDI+ 抗锯齿描边"满幅锯齿":
  拖拽超出预渲染余量时走整块重画,整宽单网格渲染实测 655ms。逐层定位:BuildPolyline 仅 0.03ms;
  同样 4481 点交给 Graphics::DrawLines —— AA round 694ms / 无 AA 29ms(23 倍)。即编码器方波在
  1~2 采样/像素时形成每像素列一次整幅跳变的锯齿,GDI+ 抗锯齿逐段扫描近竖直满幅边,代价爆炸
  (原实现只在 >2 采样/像素或"梳状"时关 AA,这一区间两个条件都不满足)。
  修法(几何完全不变,只改"何时走包络/何时关 AA"):
   1) 包络带阈值从"每像素 2 个采样(budget)"降到"每像素 1 个采样"(visibleCount > plotWidth);
   2) EmitEnvelopeBand 新增 laneHeight 形参,扩展"梳状(关 AA)"判据:多数列列内跨幅 > 0.5x网格高,
      或相邻列上下包络整幅跳变(> 0.25x网格高)-> 本段关闭抗锯齿。
  实测:整宽单网格渲染 655ms -> 47.7ms;补条 6.9 -> 2.4ms;中键拖拽 200 帧 avg 4.5ms /
  max 5.8ms / 0 帧 >8ms(修复前 13~24ms 且含 700ms 尖峰)。目视核对(mode 9 导出 PNG)正常。

C —— 并行网格图层渲染(用户明确解除多线程限制):
  文档中"图层渲染一律在 UI 线程内同步完成 / 绝不要搬到后台线程"的约束已按用户要求解除,但根因
  (共享可变渲染状态)依然成立,故并行的唯一安全姿势是"每线程持有隔离渲染上下文"。
  实现 PlotView::RebuildLaneLayersParallel:每线程一个隔离 PlotView(m_tab 指向自有 m_workerTab
  副本,m_area/m_plotRect/m_lanes 都是它自己的),各自渲染不同 laneIndex 到不同的 m_layerFront 元素;
  主线程 join 后按真实网格下标重算 LaneLayerKey。
  踩坑:BuildLaneVisuals() 在 m_tab==nullptr 时会清空 m_lanes -> 不能靠置空 m_tab 隔离,必须给它一个
  非空的、自有的 TabConfig(m_workerTab)。
  正确性:并行 vs 顺序逐网格比较 0 差异(5 网格全 0)。
  诚实结论:并行仅 1.03x —— 同机自检证明 4 线程 CPU 真并行(3.98x,hw=20),故瓶颈是 GDI+ 光栅化
  本身的串行化,不是调度。因此并行对光栅化无收益,仍保持"视界停后每帧补 1 个网格"(一次补齐只会把
  一帧从 ~10ms 抬到 ~31ms,更易掉帧);并行只用于本来就要一次画完的 RebuildWideBuffer()。

验证:dragperf mode 0/1/3/5/6/8/9;pancheck 7 场景全 dy=0;zoomcheck mode 0/13/14 无回归
(f1 8~9ms、缩放收敛 3649 差异=预存在残差);make clean && make all 零警告。
文档同步:§9.18 新增;§11.2 第 5、14 条改为"解禁但必须隔离状态"的新表述。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-13 03:16:47 +08:00
1 parent 065b948297
commit 20b90848fe
3 files changed
+467 -27

No files matched your search

+80 -2
View File
@@ -605,6 +605,80 @@ Documents/ 项目文档(含本文件)
mode 14(光标不跟随视界)。
- `make clean && make all` 零警告。
### 9.18 深放大中键拖拽卡顿根因(逐点描边 AA 病态)+ 横向补条裁剪 + 并行网格图层(本轮)
用户报告:`VERY_BIG_DATA.CSV` 只显示**编码器A相**、选区放大到 `[10.2719, 10.2831] s`
(≈1.12ms 一大格)后,中键拖拽视界**非常非常卡顿**。用户同时要求:**做 B 与 C**
(B=拆分宽基底路径耗时定位瓶颈;C=解除"不允许多线程"限制并实现并行网格渲染)。
#### 一、B:宽基底路径耗时拆解(`.preflight/perf/dragperf` mode 1)
在该视界逐项计时:`RollWideBuffer`(补条重绘)**13.08ms** / `BlitWideBuffer` 0.50 /
`PaintTimeAxis` 0.78 / `PaintLaneDividers` 0.04 / `PaintLaneDerivedOverlays` 0.00 /
`CaptureDecorCache` 0.03 —— **瓶颈全在 `RollWideBuffer` 的"补条重绘"**。
#### 二、补条重绘为什么慢 + 裁剪修复
- 补条只覆盖图层位图的**一条窄竖带**(`m_area` 的 x 区间),但折线原本按 `m_plotRect`(整条宽窗口
=2× 视界)构建整段顶点并交给 GDI+ 描边 —— 即使只画窄带,`DrawLines` 仍逐段提交。
- 修法:新增 `m_stripRenderActive/m_stripX0/m_stripX1`,**横向补条时把折线采样区间裁剪到条带对应
x 范围**(两侧各留 `max(4, lineWidth+3)` 像素余量,避免接缝与抗锯齿溢出),并**旁路折线缓存**
(补条只构建片段,写进缓存会污染整段复用)。
- ⚠️ **裁剪只对"逐点描边"路径生效,且必须在"包络/描边"分支判定之后**:若先裁剪,`visibleCount`
会变成条带内采样数,可能改变路径判定 → 条带与整块渲染像素不一致(出现接缝)。
- 实测 `RollWideBuffer` **13.08ms → 1.73ms**;中键拖拽整帧 **13~24ms → ~3ms**。
- 正确性(`.preflight/perf/dragperf` mode 3):人为破坏一条竖带后用补条重绘,
与整块渲染逐像素比较 **0 差异**(条带左/中/右/宽条 4 种位置都是 0)。
#### 三、真正的病态根因:GDI+ 抗锯齿描边"满幅锯齿"(本轮最关键)
修掉补条后,**一旦拖拽超出预渲染余量**(`RollWideBuffer` 走整块重画)或缩放落到该视界,
仍会卡:实测**整宽单网格渲染 655ms**(补条仅 1.7ms)。逐层定位:
- `BuildPolyline` 只 0.03ms(4481 点、`breaks=1`);
- 直接把同样的 4481 点交给 `Graphics::DrawLines`:**AA round ≈694ms / 无 AA ≈29ms(23 倍)**;
- 把同样的几何做成"像素列包络带"再 `FillPath`:同样慢。
即:该视界下编码器方波形成**每像素列一次整幅跳变的锯齿**,采样 1~2 个/像素,
GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(关抗锯齿无效是因为原实现只在
`visibleCount > budget(=2/px)` 或"梳状"时关 AA,这一区间两个条件都不满足)。
修法(**几何完全不变,只改"何时走包络 / 何时关 AA"**):
1. **包络带阈值从"每像素 2 个采样(`budget`)"降到"每像素 1 个采样"**:`useEnvelopeBand =
visibleCount > m_plotRect.Width`。1~2 采样/像素区间逐列 min/max 与折线几乎等价(且保留 AA)。
2. **扩展"梳状(关 AA)"判据**(`EmitEnvelopeBand` 新增形参 `laneHeight`):除了原有的"相邻列包络不相交",
再加入「多数列在列内就跨过 >0.5×网格高」或「相邻列上下包络整幅跳变(> 0.25×网格高)」
→ 判为梳状,**本段关闭抗锯齿**(几何逐列 min/max 一字未改)。
3. 实测:整宽单网格渲染 **655ms → 47.7ms**,横向补条 **6.9 → 2.4ms**;
中键拖拽 200 帧:**avg 4.5ms / max 5.8ms / 0 帧 >8ms**(修复前 13~24ms 且含 700ms 尖峰)。
4. 目视核对(`.preflight/perf/dragperf` mode 9 导出 PNG):编码器波形为正确的密集方波包络带。
#### 四、C:并行网格图层渲染(**用户明确解除"不允许多线程"约束**)
- 文档中"图层渲染一律在 UI 线程内同步完成 / 绝不要搬到后台线程"的约束**已按用户要求解除**
(见 §11.2 第 5、14 条的新表述),但**根因(共享可变渲染状态)依然成立** —— 因此并行的
唯一安全姿势是**每条线程持有隔离的渲染上下文**。
- 实现(`PlotView::RebuildLaneLayersParallel`):为每条工作线程准备一个**隔离的 `PlotView`**
(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),
各自把不同 `laneIndex` 的图层渲染到**不同的 `m_layerFront` 元素**;主线程 join 后按**真实网格下标**
重算 `LaneLayerKey`(工作线程内部按 `laneIndex=0` 渲染)。
- ⚠️ **踩坑**:`BuildLaneVisuals()` 在 `m_tab == nullptr` 时**会清空 `m_lanes`** → 不能靠"把 `m_tab` 置空
来隔离",必须给它一个**非空的、自有的** `TabConfig`(`m_workerTab`)。
- 正确性(`.preflight/perf/dragperf` mode 5):并行 vs 顺序逐网格比较 **0 差异**(5 网格全 0)。
- **收益(诚实结论)**:`RebuildWideBuffer` 并行 31.1ms vs 顺序 32.0ms → **仅 1.03x**。
同一进程自检证明 **4 线程 CPU 真并行(3.98x,hw=20)**,因此**瓶颈是 GDI+ 光栅化本身的串行化**,
不是调度。→ **并行对"光栅化"没有收益**;因此**保持"视界停后每帧补 1 个网格"**的策略
(把一帧从 ~10ms 抬到 ~31ms 反而更容易掉帧),并行只用于"本来就要一次画完"的 `RebuildWideBuffer()`。
#### 五、验证
- `.preflight/perf/dragperf`:mode 0(拖拽帧时)、mode 1(B 拆解)、mode 3(补条逐像素)、
mode 5(并行逐像素)、mode 6(重建耗时)、mode 8(线程真并行自检)、mode 9(PNG 目视)。
- `.preflight/perf/pancheck` 7 场景全 `dy=0`;`.preflight/perf/zoomcheck` mode 0/13/14 无回归
(f1 8~9ms,缩放收敛 3649 差异=预存在残差)。
- `make clean && make all` 零警告。
---
## 10. 变更日志(本文件维护记录)
@@ -637,6 +711,7 @@ Documents/ 项目文档(含本文件)
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住中键把视界纵向移动好几个网格高(或横向拖很远)后,出现大量绘制错误与持续抽搐"**:根因是 `RollWideBuffer` 对"位移超出余量"只做**排队等后续帧重画**(`QueueLaneRebuild` + `continue`)—— 该帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;且每帧都重新入队而 `RenderPendingLaneRebuilds(1)` 一帧只补 1 个网格 → **补不完 = 持续抽搐**;纵向余量只有 ±半个网格高,正常速度的纵向拖拽每帧就超过它,故几乎必现。修法:**只有"内容真的变了"才排队**,距离类问题一律本帧就地解决 —— 位移小于画布则滚动(O(1) BitBlt + 只补暴露细条),超出画布则**立刻整块重画并重新居中**(0.4ms/网格)。验证:新增探针 `.preflight/dragcheck` 按用户要求**全程按住中键不放**地做检查(图层条带逐像素 / 画布覆盖率 / 画面空白行数与墨迹占比),并把纵向滚动临时关掉做 A/B:立刻报 `layer band diff 76221 / drawing errors 11`,恢复后 **0 错误**;`uitest` **463/463**、`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 |
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 |
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 |
---
@@ -666,7 +741,10 @@ Documents/ 项目文档(含本文件)
2. 每网格图层**只含基底**(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例 —— 它们锚定网格左缘、不随时间视界移动)。
3. 图层位图宽度 = `width + 2*max(1, width/2)` ≈ **2 × 视界宽**(左右各半屏,可平移 ±半屏)。
4. `RenderLaneLayerRegion` 结束后**必须** `BuildLaneVisuals()`(重算 `m_laneContentHeight`)**再**还原 `LaneScroll` 与各网格 `yMin/yMax`;漏还原任何一项 → 算出的图层指纹与实际渲染不符 → 每帧重复重画(白耗性能;旧版有后台线程时会死循环卡死)。
5. ⚠️ **图层渲染一律在 UI 线程内同步完成**(`Paint` 全程持有 `m_renderMutex`)。**绝不要把图层渲染搬到后台线程**:它会临时改写 `m_area`/`m_plotRect`/`Tab().ViewMin()/ViewMax()`/各网格矩形,而这些状态被 UI 线程的消息处理**无锁读/写**(平移读视界、命中测试读网格矩形),后台的"保存→改写→恢复"会**覆盖用户正在进行的平移**(视界弹回)并让命中测试错位 —— 这就是 2026-10-12 移除后台图层线程的原因(详见 §12 本轮)。
5. ⚠️ **图层渲染默认在 UI 线程内同步完成**,但**多线程渲染「已获用户明确解禁」**(2026-10-13)。要点:
- 渲染函数(`RenderLaneLayerRegion` / `PaintLane`)会临时改写 `m_area`/`m_plotRect`/视界/网格矩形,**共享这些状态的并发渲染会互相覆盖**(视界弹回、命中错位)—— 这正是 2026-10-12 移除"后台图层线程"的根因。
- **允许并行的唯一安全姿势:每个工作线程持有"隔离的渲染上下文"**。已落地的实现见 `PlotView::RebuildLaneLayersParallel()`:为每条线程准备一个**隔离的 `PlotView`**(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),各自把不同 `laneIndex` 的图层位图渲染到**不同的 `m_layerFront` 元素**(互不重叠),主线程 join 后按**真实网格下标**重算 `LaneLayerKey`。
- 实测:并行结果与顺序渲染**逐像素 0 差异**(5 网格全部 0)。⚠️ 但**本机 GDI+ 光栅化会被串行化**(4 线程 CPU 真并行 3.98x,而并行渲染 5 网格仅 **1.03x**)→ 光栅化并行**没有收益**,因此**不要把"逐帧预算 1 个网格"改成"一帧补齐全部"**(那只会把一帧从 ~10ms 抬到 ~31ms)。并行仅用于"本来就要一次画完"的 `RebuildWideBuffer()`。
6. 每个图层记录**自己的**时间映射 `LaneLayer::wideMin`;滚动与贴图必须**逐网格**按各自 `wideMin` 计算,**绝不共用一个全局锚点**(否则各网格被平移不同的真实距离 → 网格间时间错位)。补细条时必须传 `layer.wideMin`(不能用"当前视界"的映射,否则有 ≤0.5px 接缝与抖动)。
7. 加锁顺序**恒定** `m_renderMutex`(recursive)→ `m_layerMutex`(图层不再有跨线程访问,但顺序约定保留)。
8. ⚠️ **前台刷新线程绝不做“整批图层重绘”**:平移/拖动期间 `RollWideBuffer()` **只**做逐网格整数像素平移 + 补细条;内容过期 / 锚点过远的网格只登记到 `m_layerRebuildPending`,由 `RenderPendingLaneRebuilds(budget)` **本帧至多重画 1 个**,其余下帧继续(并 `PostMessage(kMessagePlotDerivedReady)` 请求重绘)。图层没覆盖到的部分 **`BlitWideBuffer` 只贴有像素的那一段**(其余留给底色,下帧补齐),**不得整块跳过**(跳过 = 网格变空 / “断线”)。
@@ -675,7 +753,7 @@ Documents/ 项目文档(含本文件)
11. `SceneContentHash` 必须覆盖 `Tab().LaneScroll()`(网格纵向滚动量)与 `SelectedRef()`;`FindStyleOnlyLanes()` 必须**逐项比较 `SceneCacheKey`**(`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变")。
12. 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)**必须同时**加在"缓存贴图快路径"与"整幅重绘路径"两侧。
13. 浮动面板必须按**层序**(`m_panelOrder`,队尾 = 最上层)绘制与命中:命中从后往前取第一个命中者,点击面板 `BringPanelToFront()`,新开面板追加到队尾。**不要再按 `kEdgePanelKinds` 固定顺序命中再 break**(否则上层 ✕ / 标题栏会被下层参数输入框抢走 → “上层面板关不掉 / 拖不动”)。
14. **不要为波形构建 / 绘制引入多线程**;已落地的多线程只有派生曲线线程池(`m_curveWorkers`)。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。
14. ⚠️ **多线程限制已解禁(2026-10-13),但必须"隔离状态"**:波形渲染可以多线程 —— 前提是每个线程用**自有的渲染上下文**(自有 `m_tab`/`m_area`/`m_plotRect`/`m_lanes`/DC),绝不共享可变渲染状态(见第 5 条的安全姿势)。已落地的并行只有:派生曲线线程池(`m_curveWorkers`)与并行网格图层渲染(`m_layerWorkers`)。⚠️ 实测**光栅化并行无收益**(GDI+ 串行化),所以并行只在"整批重建"这类本来就要一次画完的地方用。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。
15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。
16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。