Files
scuc-qt-course/docs/teaching/examples/mandelbrot_renderer/README.md
T
张宗平 6773f9b29e Day4 教学示例:Mandelbrot 分形渲染器(QPainter 绘图 + 多线程 + 事件处理综合演示)
三段式对比直击 Day4 slides「主线程耗时=冻结」痛点:主线程渲染冻结 / 后台单线程不卡但慢 / 后台多线程不卡且快。
- QThreadPool + QRunnable 分水平条带并行,排队信号槽回传,epoch 版本号防过期覆盖
- wheelEvent 缩放(鼠标为锚)+ mouseEvent 拖拽平移 + paintEvent 画 QImage
- 子目录自带独立 CMakeLists.txt,可单独构建/共享(双模式约定同 lineedit_eye_toggle)
- 配套 README(概念/走读/双构建/5 踩坑点/练习)+ 设计规格
- 已验证:独立工程 offscreen 运行 exit=0;主工程构建 exit=0(w64devkit g++16 + mingw73 Qt5.14.2)

注:主工程 examples/CMakeLists.txt 的接入(mandelbrot + lineedit_eye_toggle + qdialog + themes 原子接入)与顶层 CMakeLists 本地调试改动未一并提交,待整体确认后单独提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:01:29 +08:00

110 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 倍** | 界面流畅、渲染耗时骤降 |
三段对比让两个结论同时成立:**耗时任务必须离开主线程**(否则冻结);**多线程进一步压榨多核**(加速比)。
## 概念映射
| 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 最小版)。