Mandelbrot
Mandelbrot 示例演示了使用 Qt 进行多线程编程。它展示了如何使用工作线程来执行耗时计算,而不会阻塞主线程的事件循环。
这里的耗时计算是曼德布罗特集,可能是有史以来最著名的分形。如今,虽然像 XaoS 这样的复杂程序提供了曼德布罗特集的真实放大缩放功能,但标准曼德布罗特算法足够慢,适合我们的需求。
在现实生活中,这里描述的方法适用于一大类问题,包括同步网络 I/O 和数据库访问,在这些情况下,用户界面必须在某些耗时操作进行时保持响应。
Mandelbrot 应用程序支持使用鼠标或键盘进行缩放和滚动。为了避免冻结主线程的事件循环(从而应用程序的用户界面),我们将所有分形计算放在一个单独的工作线程中。当线程完成渲染分形时,它会发出信号。
当工作线程正在重新计算分形以反映新的缩放因子位置时,主线程只是简单地缩放之前渲染的位图,以提供即时反馈。虽然结果最终不如工作线程提供的那么好,但至少使应用程序变得更响应。下面的截图中显示了原始图像、缩放图像和重新渲染的图像。
同样,当用户滚动时,之前的位图会立即滚动,同时工作线程正在渲染图像。
应用程序由两个类组成
如果您还不熟悉 Qt 的线程支持,我们建议您先从阅读 Qt 的线程支持概览 开始。
RenderThread 类定义
我们从 RenderThread
类的定义开始。
class RenderThread : public QThread { Q_OBJECT public: RenderThread(QObject *parent = nullptr); ~RenderThread(); void render(double centerX, double centerY, double scaleFactor, QSize resultSize, double devicePixelRatio); static void setNumPasses(int n) { numPasses = n; } static QString infoKey() { return QStringLiteral("info"); } signals: void renderedImage(const QImage &image, double scaleFactor); protected: void run() override; private: static uint rgbFromWaveLength(double wave); QMutex mutex; QWaitCondition condition; double centerX; double centerY; double scaleFactor; double devicePixelRatio; QSize resultSize; static int numPasses; bool restart = false; bool abort = false; static constexpr int ColormapSize = 512; uint colormap[ColormapSize]; };
该类继承自 QThread 以使其能够在分离的线程中运行。除了构造函数和析构函数以外,render()
是唯一的公共函数。每次线程完成渲染一个图像时,它都会发出 renderedImage()
信号。
受保护的 run()
函数是重新实现自 QThread。它在线程启动时自动调用。
在 private
部分,有一个 QMutex、一个 QWaitCondition 以及一些其他的数据成员。互斥量保护其他数据成员。
RenderThread 类实现
RenderThread::RenderThread(QObject *parent) : QThread(parent) { for (int i = 0; i < ColormapSize; ++i) colormap[i] = rgbFromWaveLength(380.0 + (i * 400.0 / ColormapSize)); }
在构造函数中,我们将 restart
和 abort
变量初始化为 false
。这些变量控制 run()
函数的流程。
我们还初始化了 colormap
数组,其中包含一系列 RGB 颜色。
RenderThread::~RenderThread() { mutex.lock(); abort = true; condition.wakeOne(); mutex.unlock(); wait(); }
析构函数可以在线程活动中的任何时候被调用。我们将 abort
设置为 true
以通知 run()
尽快停止运行。我们还调用 QWaitCondition::wakeOne(),如果线程正在睡眠,则唤醒线程。(当我们在回顾 run()
时,将线程置于睡眠状 chắn,当它没有事情做时。)
需要注意的是,run()
在其自己的线程(工作线程)中执行,而 RenderThread
的构造函数和析构函数(以及 render()
函数)是由创建工作线程的线程调用的。因此,我们需要一个互斥锁来保护对 abort
和 condition
变量的访问,这些变量可能会被 run()
在任何时间访问。
在析构函数的末尾,我们调用 QThread::wait(),等待 run()
退出,然后才调用基类的析构函数。
void RenderThread::render(double centerX, double centerY, double scaleFactor, QSize resultSize, double devicePixelRatio) { QMutexLocker locker(&mutex); this->centerX = centerX; this->centerY = centerY; this->scaleFactor = scaleFactor; this->devicePixelRatio = devicePixelRatio; this->resultSize = resultSize; if (!isRunning()) { start(LowPriority); } else { restart = true; condition.wakeOne(); } }
render()
函数由 MandelbrotWidget
在需要生成曼德布罗特集的新图像时调用。参数 centerX
、centerY
和 scaleFactor
指定要渲染的分形部分;resultSize
指定结果 QImage 的大小。
该函数将参数存储在成员变量中。如果线程尚未运行,则会启动它;否则,它将 restart
设置为 true
(告诉 run()
停止任何未完成的计算,并使用新参数重新开始),然后唤醒可能正在睡眠的线程。
void RenderThread::run() { QElapsedTimer timer; forever { mutex.lock(); const double devicePixelRatio = this->devicePixelRatio; const QSize resultSize = this->resultSize * devicePixelRatio; const double requestedScaleFactor = this->scaleFactor; const double scaleFactor = requestedScaleFactor / devicePixelRatio; const double centerX = this->centerX; const double centerY = this->centerY; mutex.unlock();
run()
是一个非常大的函数,因此我们将它分解成几个部分。
函数的主体是一个无限循环,它首先将渲染参数存储在局部变量中。与往常一样,我们使用类的互斥锁来保护对成员变量的访问。将成员变量存储在局部变量中可以最小化需要由互斥锁保护的部分代码量。这确保了主线程在需要访问 RenderThread
的成员变量时(例如,在 render()
中)永远不会阻塞太长时间。
forever
关键字是 Qt 的伪关键字。
const int halfWidth = resultSize.width() / 2; const int halfHeight = resultSize.height() / 2; QImage image(resultSize, QImage::Format_RGB32); image.setDevicePixelRatio(devicePixelRatio); int pass = 0; while (pass < numPasses) { const int MaxIterations = (1 << (2 * pass + 6)) + 32; constexpr int Limit = 4; bool allBlack = true; timer.restart(); for (int y = -halfHeight; y < halfHeight; ++y) { if (restart) break; if (abort) return; auto scanLine = reinterpret_cast<uint *>(image.scanLine(y + halfHeight)); const double ay = centerY + (y * scaleFactor); for (int x = -halfWidth; x < halfWidth; ++x) { const double ax = centerX + (x * scaleFactor); double a1 = ax; double b1 = ay; int numIterations = 0; do { ++numIterations; const double a2 = (a1 * a1) - (b1 * b1) + ax; const double b2 = (2 * a1 * b1) + ay; if ((a2 * a2) + (b2 * b2) > Limit) break; ++numIterations; a1 = (a2 * a2) - (b2 * b2) + ax; b1 = (2 * a2 * b2) + ay; if ((a1 * a1) + (b1 * b1) > Limit) break; } while (numIterations < MaxIterations); if (numIterations < MaxIterations) { *scanLine++ = colormap[numIterations % ColormapSize]; allBlack = false; } else { *scanLine++ = qRgb(0, 0, 0); } } } if (allBlack && pass == 0) { pass = 4; } else { if (!restart) { QString message; QTextStream str(&message); str << " Pass " << (pass + 1) << '/' << numPasses << ", max iterations: " << MaxIterations << ", time: "; const auto elapsed = timer.elapsed(); if (elapsed > 2000) str << (elapsed / 1000) << 's'; else str << elapsed << "ms"; image.setText(infoKey(), message); emit renderedImage(image, requestedScaleFactor); } ++pass; } }
然后是算法的核心。我们不是尝试创建完美的曼德布罗特集图像,而是进行多次遍历,并生成越来越精确(和计算成本越来越高)的分数逼近。
通过将对目标大小应用设备像素比,我们创建一个具有高分辨率的图砖(参见 绘制高分辨率图砖和图像)。
如果在循环中发现 restart
已被设置为 true
(由 render()
完成),则立即退出循环,以便控制快速返回到最外层循环的顶部(forever
循环),并获取新的渲染参数。同样,如果发现 abort
已被设置为 true
(由 RenderThread
析构函数完成),则立即从函数返回,终止线程。
核心算法超出了本教程的范围。
mutex.lock(); if (!restart) condition.wait(&mutex); restart = false; mutex.unlock(); } }
一旦完成所有迭代,我们就调用 QWaitCondition::wait() 以使线程进入睡眠状态,除非 restart
为 true
。在没有事情可做时,保留工作线程无限期循环是没有任何用处的。
uint RenderThread::rgbFromWaveLength(double wave) { double r = 0; double g = 0; double b = 0; if (wave >= 380.0 && wave <= 440.0) { r = -1.0 * (wave - 440.0) / (440.0 - 380.0); b = 1.0; } else if (wave >= 440.0 && wave <= 490.0) { g = (wave - 440.0) / (490.0 - 440.0); b = 1.0; } else if (wave >= 490.0 && wave <= 510.0) { g = 1.0; b = -1.0 * (wave - 510.0) / (510.0 - 490.0); } else if (wave >= 510.0 && wave <= 580.0) { r = (wave - 510.0) / (580.0 - 510.0); g = 1.0; } else if (wave >= 580.0 && wave <= 645.0) { r = 1.0; g = -1.0 * (wave - 645.0) / (645.0 - 580.0); } else if (wave >= 645.0 && wave <= 780.0) { r = 1.0; } double s = 1.0; if (wave > 700.0) s = 0.3 + 0.7 * (780.0 - wave) / (780.0 - 700.0); else if (wave < 420.0) s = 0.3 + 0.7 * (wave - 380.0) / (420.0 - 380.0); r = std::pow(r * s, 0.8); g = std::pow(g * s, 0.8); b = std::pow(b * s, 0.8); return qRgb(int(r * 255), int(g * 255), int(b * 255)); }
函数 rgbFromWaveLength()
是一个辅助函数,它将波长转换为与 32 位 QImage 兼容的 RGB 值。它由构造函数调用,以初始化 colormap
数组,其中包含令人愉悦的颜色。
MandelbrotWidget 类定义
类 MandelbrotWidget
使用 RenderThread
在屏幕上绘制 Mandelbrot 系列。以下是该类的定义:
class MandelbrotWidget : public QWidget { Q_DECLARE_TR_FUNCTIONS(MandelbrotWidget) public: MandelbrotWidget(QWidget *parent = nullptr); protected: QSize sizeHint() const override { return {1024, 768}; }; void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void keyPressEvent(QKeyEvent *event) override; #if QT_CONFIG(wheelevent) void wheelEvent(QWheelEvent *event) override; #endif void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; #ifndef QT_NO_GESTURES bool event(QEvent *event) override; #endif private: void updatePixmap(const QImage &image, double scaleFactor); void zoom(double zoomFactor); void scroll(int deltaX, int deltaY); #ifndef QT_NO_GESTURES bool gestureEvent(QGestureEvent *event); #endif RenderThread thread; QPixmap pixmap; QPoint pixmapOffset; QPoint lastDragPos; QString help; QString info; double centerX; double centerY; double pixmapScale; double curScale; };
该小部件重写了来自 QWidget 的许多事件处理程序。此外,它还有一个 updatePixmap()
抽象槽,我们将它与工作线程的 renderedImage()
信号相关联,以便在收到线程的新数据时更新显示。
在私有变量中,我们有 thread
类型为 RenderThread
的线程和 pixmap
,其中包含最后一个渲染的图像。
MandelbrotWidget 类实现
constexpr double DefaultCenterX = -0.637011; constexpr double DefaultCenterY = -0.0395159; constexpr double DefaultScale = 0.00403897; constexpr double ZoomInFactor = 0.8; constexpr double ZoomOutFactor = 1 / ZoomInFactor; constexpr int ScrollStep = 20;
实现从几个稍后需要用到的常量开始。
MandelbrotWidget::MandelbrotWidget(QWidget *parent) : QWidget(parent), centerX(DefaultCenterX), centerY(DefaultCenterY), pixmapScale(DefaultScale), curScale(DefaultScale) { help = tr("Zoom with mouse wheel, +/- keys or pinch. Scroll with arrow keys or by dragging."); connect(&thread, &RenderThread::renderedImage, this, &MandelbrotWidget::updatePixmap); setWindowTitle(tr("Mandelbrot")); #if QT_CONFIG(cursor) setCursor(Qt::CrossCursor); #endif }
构造函数中有趣的部分是 QObject::connect() 调用。
虽然它看起来像两个 QObject 之间的标准信号-槽连接,但由于信号是在与接收者不同的线程中发出的,所以这个连接实际上是一个 queued connection。 这些连接是异步的(即非阻塞的),意味着在发出 emit
语句之后,某个时间点将调用槽。更重要的是,槽将在接收者所在的线程中调用。在这里,信号在工作线程中发出,槽在控制返回到事件循环时的 GUI 线程中执行。
使用队列连接时,Qt 必须存储传递给信号的参数副本,以便稍后可以将它们传递给槽。Qt 知道如何处理许多 C++ 和 Qt 类型的副本,因此,对于 QImage 无需采取进一步操作。如果使用自定义类型,则需要在使用该类型作为参数在队列连接中使用之前调用模板函数 qRegisterMetaType()。
void MandelbrotWidget::paintEvent(QPaintEvent * /* event */) { QPainter painter(this); painter.fillRect(rect(), Qt::black); if (pixmap.isNull()) { painter.setPen(Qt::white); painter.drawText(rect(), Qt::AlignCenter|Qt::TextWordWrap, tr("Rendering initial image, please wait...")); return; }
在 paintEvent() 中,我们首先用黑色填充背景。如果没有东西要绘制(pixmap
为空),我们在小部件上显示一条消息,让用户耐心等待,并立即从函数返回。
if (qFuzzyCompare(curScale, pixmapScale)) { painter.drawPixmap(pixmapOffset, pixmap); } else { const auto previewPixmap = qFuzzyCompare(pixmap.devicePixelRatio(), qreal(1)) ? pixmap : pixmap.scaled(pixmap.deviceIndependentSize().toSize(), Qt::KeepAspectRatio, Qt::SmoothTransformation); const double scaleFactor = pixmapScale / curScale; const int newWidth = int(previewPixmap.width() * scaleFactor); const int newHeight = int(previewPixmap.height() * scaleFactor); const int newX = pixmapOffset.x() + (previewPixmap.width() - newWidth) / 2; const int newY = pixmapOffset.y() + (previewPixmap.height() - newHeight) / 2; painter.save(); painter.translate(newX, newY); painter.scale(scaleFactor, scaleFactor); const QRectF exposed = painter.transform().inverted().mapRect(rect()) .adjusted(-1, -1, 1, 1); painter.drawPixmap(exposed, previewPixmap, exposed); painter.restore(); }
如果位图具有正确的缩放因子,我们将直接在位图上绘制。
否则,我们创建一个要显示的预览位图,直到计算完成,然后相应地转换坐标系统。
由于我们将对图形绘制器使用转换,并使用不支持高分辨率位图的 QPainter::drawPixmap() 的重载,我们创建一个具有设备像素比为 1 的位图。
通过使用缩放的画家矩阵反向映射小部件的矩形,我们还确保只绘制位图的暴露区域。调用 QPainter::save() 和 QPainter::restore() 确保之后进行的任何绘图都使用标准坐标系。
const QFontMetrics metrics = painter.fontMetrics(); if (!info.isEmpty()){ const int infoWidth = metrics.horizontalAdvance(info); const int infoHeight = (infoWidth/width() + 1) * (metrics.height() + 5); painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 127)); painter.drawRect((width() - infoWidth) / 2 - 5, 0, infoWidth + 10, infoHeight); painter.setPen(Qt::white); painter.drawText(rect(), Qt::AlignHCenter|Qt::AlignTop|Qt::TextWordWrap, info); } const int helpWidth = metrics.horizontalAdvance(help); const int helpHeight = (helpWidth/width() + 1) * (metrics.height() + 5); painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 127)); painter.drawRect((width() - helpWidth) / 2 - 5, height()-helpHeight, helpWidth + 10, helpHeight); painter.setPen(Qt::white); painter.drawText(rect(), Qt::AlignHCenter|Qt::AlignBottom|Qt::TextWordWrap, help); }
在 paint 事件处理程序结束时,我们绘制一个文本字符串和一个半透明的矩形,位于分形之上。
void MandelbrotWidget::resizeEvent(QResizeEvent * /* event */) { thread.render(centerX, centerY, curScale, size(), devicePixelRatio()); }
每次用户调整小部件大小时,我们都调用 render()
以开始生成新的图像,具有相同的 centerX
,centerY
和 curScale
参数,但具有新的小部件大小。
请注意,我们依赖于Qt在第一次显示小部件时自动调用resizeEvent()
来生成初始图像。
void MandelbrotWidget::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Plus: zoom(ZoomInFactor); break; case Qt::Key_Minus: zoom(ZoomOutFactor); break; case Qt::Key_Left: scroll(-ScrollStep, 0); break; case Qt::Key_Right: scroll(+ScrollStep, 0); break; case Qt::Key_Down: scroll(0, -ScrollStep); break; case Qt::Key_Up: scroll(0, +ScrollStep); break; case Qt::Key_Q: close(); break; default: QWidget::keyPressEvent(event); } }
按键事件处理器提供了一些键盘绑定,以方便那些没有鼠标的用户。将稍后介绍zoom()
和scroll()
函数。
void MandelbrotWidget::wheelEvent(QWheelEvent *event) { const int numDegrees = event->angleDelta().y() / 8; const double numSteps = numDegrees / double(15); zoom(pow(ZoomInFactor, numSteps)); }
我们将轮盘事件处理器重新实现,以便使用鼠标滚轮来控制缩放级别。QWheelEvent::angleDelta()返回轮式鼠标移动的角度,以八分之一度为单位。对于大多数鼠标,一个滚轮步骤对应于15度。我们找出鼠标步数,并确定结果缩放因子。例如,如果我们有两个正向滚轮步骤(即+30度),则缩放因子变为ZoomInFactor
的平方,即0.8 * 0.8 = 0.64。
void MandelbrotWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) lastDragPos = event->position().toPoint(); }
通过QGesture
已实现了捏合缩放,详情请参阅小部件和图形视图中手势。
#ifndef QT_NO_GESTURES bool MandelbrotWidget::gestureEvent(QGestureEvent *event) { if (auto *pinch = static_cast<QPinchGesture *>(event->gesture(Qt::PinchGesture))) { if (pinch->changeFlags().testFlag(QPinchGesture::ScaleFactorChanged)) zoom(1.0 / pinch->scaleFactor()); return true; } return false; } bool MandelbrotWidget::event(QEvent *event) { if (event->type() == QEvent::Gesture) return gestureEvent(static_cast<QGestureEvent*>(event)); return QWidget::event(event); } #endif
当用户按下左鼠标按钮时,我们将鼠标指针位置存储在lastDragPos
中。
void MandelbrotWidget::mouseMoveEvent(QMouseEvent *event) { if (event->buttons() & Qt::LeftButton) { pixmapOffset += event->position().toPoint() - lastDragPos; lastDragPos = event->position().toPoint(); update(); } }
当用户移动鼠标指针时左鼠标按钮被按下,我们调整pixmapOffset
以在偏移位置绘制位图,并调用QWidget::update()强制重画。
void MandelbrotWidget::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { pixmapOffset += event->position().toPoint() - lastDragPos; lastDragPos = QPoint(); const auto pixmapSize = pixmap.deviceIndependentSize().toSize(); const int deltaX = (width() - pixmapSize.width()) / 2 - pixmapOffset.x(); const int deltaY = (height() - pixmapSize.height()) / 2 - pixmapOffset.y(); scroll(deltaX, deltaY); } }
当左鼠标按钮释放时,我们像鼠标移动时一样更新pixmapOffset
,并将lastDragPos
重置为默认值。然后,我们调用scroll()
以渲染新位置的新图像。(仅调整pixmapOffset
是不够的,因为在拖动位图时露出的区域是以黑色绘制的。)
void MandelbrotWidget::updatePixmap(const QImage &image, double scaleFactor) { if (!lastDragPos.isNull()) return; info = image.text(RenderThread::infoKey()); pixmap = QPixmap::fromImage(image); pixmapOffset = QPoint(); lastDragPos = QPoint(); pixmapScale = scaleFactor; update(); }
当工作线程完成渲染图像时,将调用updatePixmap()
槽。我们首先检查是否有拖动,如果是,则不做任何操作。在正常情况下,我们将图像存储在pixmap
中,并重新初始化其他一些成员。最后,我们调用QWidget::update()来刷新显示。
此时,您可能想知道为什么我们使用QImage作为参数,而使用QPixmap作为数据成员。为什么不坚持一个类型呢?原因是QImage是唯一支持直接像素操作的类,这是我们工作线程所需的。另一方面,在图像可以绘制到屏幕之前,它必须转换为位图。最好在这里一次完成转换,而不是在paintEvent()
中。
void MandelbrotWidget::zoom(double zoomFactor) { curScale *= zoomFactor; update(); thread.render(centerX, centerY, curScale, size(), devicePixelRatio()); }
在zoom()
中,我们重新计算curScale
。然后我们调用QWidget::update()来绘制缩放的位图,并要求工作线程渲染与新的curScale
值相对应的新图像。
void MandelbrotWidget::scroll(int deltaX, int deltaY) { centerX += deltaX * curScale; centerY += deltaY * curScale; update(); thread.render(centerX, centerY, curScale, size(), devicePixelRatio()); }
scroll()
类似于zoom()
,只不过受影响的参数是centerX
和centerY
。
主()函数
应用程序的线程特性对其main()
函数没有影响,该函数与通常一样简单
int main(int argc, char *argv[]) { QApplication app(argc, argv); QCommandLineParser parser; parser.setApplicationDescription(u"Qt Mandelbrot Example"_s); parser.addHelpOption(); parser.addVersionOption(); QCommandLineOption passesOption(u"passes"_s, u"Number of passes (1-8)"_s, u"passes"_s); parser.addOption(passesOption); parser.process(app); if (parser.isSet(passesOption)) { const auto passesStr = parser.value(passesOption); bool ok; const int passes = passesStr.toInt(&ok); if (!ok || passes < 1 || passes > 8) { qWarning() << "Invalid value:" << passesStr; return -1; } RenderThread::setNumPasses(passes); } MandelbrotWidget widget; widget.grabGesture(Qt::PinchGesture); widget.show(); return app.exec(); }
版权所有© 2024 Qt公司有限公司。其中包含的文档贡献是各自所有者的版权。本提供的文档遵循自由软件基金会发布的GNU自由文档许可协议第1.3版http://www.gnu.org/licenses/fdl.html条款。Qt及其相关标志是芬兰以及全球其他国家的Qt公司有限公司的商标。所有其他商标均为各自所有者的财产。