이 페이지에서

만델브로트

만델브로트 예제는 Qt를 사용한 멀티스레드 프로그래밍을 보여줍니다. 이 예제는 메인 스레드의 이벤트 루프를 차단하지 않고 작업자 스레드를 사용하여 고부하 계산을 수행하는 방법을 보여줍니다.

만델브로트 예제의 스크린샷

여기서 말하는 고부하 연산은 아마도 세계에서 가장 유명한 프랙탈인 만델브로트 집합입니다. 요즘에는 XaoS와 같은 정교한 프로그램들이 만델브로트 집합을 실시간으로 확대/축소해 주지만, 표준 만델브로트 알고리즘은 본 예제의 목적에 딱 맞는 정도로 느립니다.

실제 상황에서는, 여기서 설명한 접근 방식이 동기식 네트워크 I/O 및 데이터베이스 액세스를 포함하여, 무거운 작업이 진행되는 동안에도 사용자 인터페이스가 반응성을 유지해야 하는 다양한 문제에 적용될 수 있습니다. ‘Blocking Fortune Client’ 예제는 TCP 클라이언트에서 동일한 원리가 작동하는 모습을 보여줍니다.

만델브로트 애플리케이션은 마우스나 키보드를 사용한 확대/축소 및 스크롤 기능을 지원합니다. 메인 스레드의 이벤트 루크(결과적으로 애플리케이션의 사용자 인터페이스)가 멈추는 것을 방지하기 위해, 모든 프랙탈 계산 작업을 별도의 워커 스레드에 배치했습니다. 이 스레드는 프랙탈 렌더링이 완료되면 신호를 발신합니다.

워커 스레드가 새로운 확대/축소 비율 위치를 반영하여 프랙탈을 재계산하는 동안, 메인 스레드는 즉시 피드백을 제공하기 위해 이전에 렌더링된 픽스맵을 단순히 확대/축소합니다. 결과는 작업자 스레드가 최종적으로 제공하는 것만큼 좋지는 않지만, 적어도 애플리케이션의 반응성을 높여줍니다. 아래 스크린샷 시퀀스는 원본 이미지, 확대/축소된 이미지, 그리고 다시 렌더링된 이미지를 보여줍니다.

원본 만델브로트 이미지확대/축소 시 크기 조정확대 후 다시 렌더링됨

마찬가지로, 사용자가 스크롤하면 이전 픽스맵이 즉시 스크롤되어 픽스맵 가장자리 너머의 렌더링되지 않은 영역이 드러나며, 이 동안 이미지는 워커 스레드에 의해 렌더링됩니다.

원래 위치도색되지 않은 부분이 있는 스크롤스크롤 후 다시 렌더링됨

이 애플리케이션은 두 개의 클래스로 구성됩니다:

  • RenderThread 는 만델브로 집합을 렌더링하는 ` QThread `의 서브클래스입니다.
  • MandelbrotWidget 는 화면에 만델브로 집합을 표시하고 사용자가 확대/축소 및 스크롤을 할 수 있게 해주는 ` QWidget `의 서브클래스입니다.

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() ` 함수의 실행 흐름을 제어합니다.

또한 일련의 RGB 색상을 포함하는 ` colormap ` 배열도 초기화합니다.

RenderThread::~RenderThread()
{
    mutex.lock();
    abort = true;
    condition.wakeOne();
    mutex.unlock();

    wait();
}

소멸자는 스레드가 활성 상태인 동안 언제든지 호출될 수 있습니다. 우리는 abort 을 true 로 설정하여 run() 가 가능한 한 빨리 실행을 중지하도록 지시합니다. 또한 스레드가 대기 중일 경우 QWaitCondition::wakeOne()를 호출하여 스레드를 깨웁니다. ( run() 를 검토할 때 보게 되겠지만, 스레드는 할 일이 없을 때 대기 상태로 전환됩니다.)

여기서 주목해야 할 중요한 점은 run() 이 자체 스레드(작업자 스레드)에서 실행되는 반면, RenderThread 의 생성자와 소멸자(그리고 render() 함수)는 작업자 스레드를 생성한 스레드에 의해 호출된다는 것입니다. 따라서 run() 에 의해 언제든지 접근될 수 있는 abort 및 condition 변수에 대한 접근을 보호하기 위해 뮤텍스가 필요합니다.

소멸자 끝부분에서는 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.start();

            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;
            }
        }

다음은 알고리즘의 핵심 부분입니다. 완벽한 만델브로트 집합 이미지를 생성하려고 시도하는 대신, 여러 번의 반복 처리를 통해 프랙탈에 대한 점점 더 정밀하고(그러나 계산 비용이 많이 드는) 근사치를 생성합니다.

대상 크기에 장치 픽셀 비율을 적용하여 고해상도 픽스맵을 생성합니다( Drawing High Resolution Versions of Pixmaps and Images 참조).

루프 내에서 restart 이 true 로 설정되어 있음을 발견하면( render() 에 의해), 즉시 루프에서 빠져나와 제어권이 외부 루프( forever 루프)의 맨 위로 빠르게 돌아가도록 하고, 새로운 렌더링 매개변수를 가져옵니다. 마찬가지로, abort 가 true 로 설정된 것을 감지하면( RenderThread 의 소멸자에 의해), 함수에서 즉시 반환하여 스레드를 종료합니다.

핵심 알고리즘은 이 튜토리얼의 범위를 벗어납니다.

        mutex.lock();
        if (!restart)
            condition.wait(&mutex);
        restart = false;
        mutex.unlock();
    }
}

모든 반복 처리가 끝나면, restart 가 true 가 아닌 한, QWaitCondition::wait()를 호출하여 스레드를 절전 상태로 전환합니다. 할 일이 없는 상태에서 워커 스레드를 무한 루프 상태로 유지하는 것은 무의미합니다.

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 를 사용하여 화면에 만델브로 집합을 그립니다. 다음은 클래스 정의입니다:

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() 신호에 연결하여 스레드로부터 새로운 데이터를 수신할 때마다 화면을 업데이트합니다.

비공개 변수 중에는 RenderThread 타입의 thread 와, 마지막으로 렌더링된 이미지를 포함하는 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 가 null인 경우), 위젯에 사용자에게 잠시 기다려 달라는 메시지를 표시하고 함수에서 즉시 반환합니다.

    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);

        QRectF exposed = painter.transform().inverted().mapRect(rect());
        exposed = exposed.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);

}

페인트 이벤트 핸들러의 마지막 부분에서, 프랙탈 위에 텍스트 문자열과 반투명한 사각형을 그립니다.

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()`는 마우스 휠 이동 각도를 8분의 1도 단위로 반환합니다. 대부분의 마우스에서 휠 한 단계는 15도에 해당합니다. 마우스 휠이 몇 단계 회전했는지 파악하여 그에 따른 확대/축소 배율을 결정합니다. 예를 들어, 양의 방향으로 휠이 두 단계(즉, +30도) 회전했다면, 확대/축소 배율은 ZoomInFactor 의 제2승, 즉 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() 함수

응용 프로그램의 멀티스레드 특성은 main() 함수에 아무런 영향을 미치지 않으며, 이 함수는 평소와 마찬가지로 간단합니다:

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QCommandLineParser parser;
    parser.setApplicationDescription(u"Qt 만델브로트 예제"_s);
    parser.addHelpOption();
    parser.addVersionOption();
    QCommandLineOption passesOption(u"passes"_s, u"반복 횟수 (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();
}

코드.qt.io의 예제 프로젝트

© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.