# Day4 Mandelbrot 分形渲染器 —— 事件 / 绘图 / 多线程 综合演示

> 对应 Day4 课程主线：**事件机制（事件 vs 信号、事件拦截）+ QPainter 绘图 + 多线程防 UI 阻塞**。
> 仓库 `p03/` 的 wiki 抓取内容里没有把这三者串起来的综合示例，本示例是手工补写的教学
> 补充，**不属于** `tools/gen_part3.py` 的生成产物。

## 一句话

滚轮缩放 / 拖拽平移探索 Mandelbrot 分形；分形渲染是 CPU 密集任务，用 `QThreadPool` +
`QRunnable` 分水平条带并行计算，主线程 `paintEvent` 只负责画图——**界面永不卡**。
再配一个「主线程渲染」按钮，**故意**在 GUI 线程同步算一帧，直观复现 Day4 slides 反复强调的
「主线程做耗时操作 = 事件循环停摆 = 整个界面冻结」。

## 三段式对比（本示例的核心教学价值）

| 操作 | 谁在算 | 事件循环 | 速度 | 你看到 |
|---|---|---|---|---|
| 「主线程渲染」按钮 | GUI 线程同步算 | **被占用→冻结** | 慢 | 按钮无响应、窗口拖不动、`paintEvent` 排队 |
| 「单线程」模式 | 线程池（1 线程）后台 | 空闲 | 慢 | 界面流畅、可继续缩放 |
| 「多线程」模式 | 线程池（N 核）后台 | 空闲 | **快 ~N 倍** | 界面流畅、渲染耗时骤降 |

三段对比让两个结论同时成立：**耗时任务必须离开主线程**（否则冻结）；**多线程进一步压榨多核**（加速比）。

> **关于「主线程渲染」的冻结时长**：单帧 820×640@256 单线程仅约 **70ms**，远低于人眼可感阈值，
> 直接算一帧看不出「冻结」。故该按钮**自适应循环渲染直到累计 ≥ 1500ms**（实测约 23 帧 / 1506ms），
> 保证在任何 CPU 上冻结都肉眼可感。冻结的本质是「事件循环被占用」，与算什么无关——循环只是模拟一个
> 持续 ~1.5s 的耗时操作。**演示技巧**：按下该按钮后立即拖动窗口或点击其它按钮，会发现整 1.5s 内
> 完全无响应，这就是事件循环停摆。

## 概念映射

| Day4 知识点 | 本示例实现 |
|---|---|
| QPainter 绘图（框架驱动，只能在 `paintEvent` 里画） | `MandelbrotWidget::paintEvent` 把累积 `QImage` 一次性 `drawImage` |
| 事件处理（重写 `xxxEvent`） | `wheelEvent`（缩放）、`mousePress/Move/ReleaseEvent`（平移）、`resizeEvent` |
| 多线程防 UI 阻塞 | `QThreadPool::globalInstance()` + `QRunnable` 分条带并行；排队信号槽回传结果 |

## 代码走读

1. **`mandelbrot_strip_task.h/.cpp`** —— 纯计算单元（可独立理解）
   - `MandelViewport`：复平面视口参数；`MandelPalette`：迭代次数→RGB 调色板。
   - `renderStrip(vp, pal, y0, y1)`：**纯函数**，渲染视口内 `[y0,y1)` 行像素。多线程 task 与
     「主线程渲染」按钮**共用**它——保证两路径算同一份分形，差异只在是否阻塞/是否并行。
   - `MandelbrotStripTask`：`public QObject, public QRunnable` 多继承，`run()` 算完一条带
     `emit stripReady(epoch, i, QImage)`。**任务持 palette/viewport 的值副本**，自包含、不引用
     主线程对象（避免悬垂）。
2. **`mandelbrot_widget.h/.cpp`** —— 交互与渲染调度
   - `scheduleRender()`：`++epoch_` → 切 `2×核数` 条带 → 每带 `new MandelbrotStripTask` 丢池。
   - `onStripReady()`：校验 `epoch == epoch_`（过期丢弃）→ 把条带画到累积 `pixmap_` → `update()`。
   - `onRenderOnMainThread()`：在 GUI 线程同步循环算整帧，**不丢池** → 事件循环停摆 → 冻结。
   - `onToggleThreadMode()`：`setMaxThreadCount(1 或 idealThreadCount())`，复用同一套分块逻辑。
3. **`main.cpp`** —— offscreen 平台下 200ms 自动退出（仓库自动化验证约定）。

## 关键机制：为什么界面不卡 / 为什么结果不会错乱

- **不卡**：耗时计算在 `QRunnable::run()`（子线程）里，主线程事件循环始终空闲，能继续响应
  鼠标键盘。`stripReady` 信号经**排队连接**自动调度回主线程执行 `onStripReady`——不需要手动加锁。
- **不错乱**：连续缩放会产生多轮渲染。每轮 `++epoch_`；旧任务的 `stripReady` 回来时
  `epoch != epoch_` 直接丢弃。这**不是主动取消**（旧任务仍在跑、算完即丢），但保证了画面正确——
  **「多线程结果回收要防竞态」**本身就是 Day4 该讲的点。

## 构建与运行

### 方式 A：作为主仓库的一部分（仓库标准）

```bash
# Linux（隔离 Qt 5.14.2）
cmake -S . -B build -G Ninja -DBUILD_QT_PART=ON \
      -DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64 -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target qt_mandelbrot_renderer
tools/run_qt.sh qt_mandelbrot_renderer                              # 有屏交互
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_mandelbrot_renderer    # 自动退出码 0
```

### 方式 B：作为独立工程（可单独打包共享）

```bash
cmake -S docs/teaching/examples/mandelbrot_renderer -B build_mandel -G Ninja \
      -DCMAKE_PREFIX_PATH=/path/to/qt/5.14.2/<kit> -DCMAKE_BUILD_TYPE=Debug
cmake --build build_mandel
./build_mandel/qt_mandelbrot_renderer
```
> 红线：无论哪种方式，Qt 必须是 5.14.x（本工程 CMakeLists 内置版本断言）。
> Linux 装隔离 Qt 见 `tools/install_qt_host.sh`；Windows 用官方 Qt 5.14.2 MinGW 安装包。

## 踩坑点（代码里复现或规避）

1. **在 `paintEvent` 之外对【窗口 widget】构造 `QPainter` = 未定义行为**。本例严格区分：
   对窗口 `this` 画只在 `paintEvent`；对离屏 `QImage pixmap_` 画（`onStripReady` / `onRenderOnMainThread`）
   是合法的——`QImage` 是 `QPaintDevice`，不受此限制。初学者常把两者混淆。
2. **`QRunnable` 没有 `Q_OBJECT`、没有信号**。要让任务发信号，须**多继承 `QObject`**（本例做法），
   或在 `run()` 里用 `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` 回调。继承 `QThread`
   重写 `run()` 是另一条路但更易踩坑（见 `qthread_worker` 示例的对照说明）。
3. **主线程渲染卡死 vs 后台流畅的本质**：不是「多线程更快」那么简单，而是**事件循环是否被
   占用**。按钮槽函数同步耗时 = 槽返回前事件循环不跑 = 所有事件（含重绘）排队 = 冻结。
4. **过期渲染结果覆盖新画面**：连续缩放时旧任务可能在新一轮之后才返回，必须用版本号
   （本例 `epoch_`）过滤，否则画面会闪回旧帧。
5. **重写 `wheelEvent`/`mouseMoveEvent` 时是否调用基类**：本例需要继续向父传播的调用了
   `QWidget::xxxEvent(e)`，自处理完毕不需要传播的（如 `wheelEvent` 缩放）则不调——这是
   `accept()/ignore()` 的实践体现。

## 练习方向（供任务卡引用）

- 把 `setMaxThreadCount` 从 1 逐步调到核数，画一张「线程数—渲染耗时」曲线，验证加速比的天花板。
- 给「主线程渲染」按钮加一个 `QApplication::processEvents()` 看似「不卡」的伪解法，观察它为什么
  不可靠（仍是单线程、仍可能丢事件）——理解为什么必须用真线程。
- 把渲染中途的旧任务**真正取消**（`QFuture`/`cancel` 标志位），对比当前「算完丢弃」的版本，
  讨论 CPU 浪费与实现复杂度的权衡。
- 给分形加平滑着色（escape time + log 归一化），消除色带。

## 参考

- Qt 官方同名示例 *Mandelbrot Example*（Qt Widgets / Painting）——本例改用「QThreadPool 分块 +
  worker-object 思路」以贴合 Day4 多线程教学，官方版用 `RenderThread` 继承 `QThread`，可对照阅读。
- 配套概念示例：`qthread_worker/`（worker-object + moveToThread 最小版）。
