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

7.1 KiB
Raw Blame History

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 与 「主线程渲染」按钮共用它——保证两路径算同一份分形,差异只在是否阻塞/是否并行。
    • MandelbrotStripTaskpublic 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:作为主仓库的一部分(仓库标准)

# 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:作为独立工程(可单独打包共享)

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.shWindows 用官方 Qt 5.14.2 MinGW 安装包。

踩坑点(代码里复现或规避)

  1. paintEvent 之外对【窗口 widget】构造 QPainter = 未定义行为。本例严格区分: 对窗口 this 画只在 paintEvent;对离屏 QImage pixmap_ 画(onStripReady / onRenderOnMainThread 是合法的——QImageQPaintDevice,不受此限制。初学者常把两者混淆。
  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 ExampleQt Widgets / Painting)——本例改用「QThreadPool 分块 + worker-object 思路」以贴合 Day4 多线程教学,官方版用 RenderThread 继承 QThread,可对照阅读。
  • 配套概念示例:qthread_worker/worker-object + moveToThread 最小版)。