Глава 8. Двух- и трехмерная графика.

В этой главе будут рассмотрены графические возможности Qt. Краеугольным камнем движка двухмерной графики в Qt является QPainter. Он может использоваться для рисования на поверхности виджета (на экране), во внутреннем буфере (pixmap) и на принтере. Кроме того, в состав Qt входит класс QCanvas, который позволяет создавать изображения из графических примитивов.

В качестве альтернативы QPainter и QCanvas, можно рассматривать библиотеку OpenGL. Она предоставляет механизмы создания трехмерной графики, но может использоваться и для рисования двухмерных изображений. Код, использующий OpenGL очень легко интегрируется в приложения Qt, мы продемонстрируем это на конкретных примерах.

8.1. Рисование средствами QPainter.

Класс QPainter используется для создания изображений на "графических устройствах", таких как виджеты или карты пикселей (pixmap). Чаще всего он используется при создании нестандартных виджетов, для придания им уникального, ни на что не похожего, внешнего вида. Однако этот класс может использоваться и для вывода графики на принтер, более подробно мы коснемся этого вопроса немного ниже.

QPainter может рисовать простые геометрические фигуры: точки, линии, прямоугольники, эллипсы, дуги, сегменты круга, замкнутые ломаные (многоугольники) и кривые Безье. Он так же может отображать карты пикселей, рисунки и текст.

Когда конструктору QPainter передается устройство для рисования, он получает часть настроек от заданного устройства, оставшиеся параметры настройки заполняет значениями по-умолчанию. Эти настройки определяют способ рисования. Тремя наиболее важными характеристиками QPainter являются перо (pen), кисть (brush) и шрифт (font).

Настройки этих характеристик могут быть выполнены с помощью функций setPen(), setBrush() и setFont().

Рисунок 8.3. Стили оформления концов линий и углов.


Рисунок 8.4. Стили кисти.


Ниже приводится код, который рисует эллипс, показанный на рисунке 8.5(а): QPainter painter(this); painter.setPen(QPen(black, 3, DashDotLine)); painter.setBrush(QBrush(red, SolidPattern)); painter.drawEllipse(20, 20, 100, 60); Следующий код рисует сегмент круга, показанный на рисунке 8.5(б): QPainter painter(this); painter.setPen(QPen(black, 5, SolidLine)); painter.setBrush(QBrush(red, DiagCrossPattern)); painter.drawPie(20, 20, 100, 60, 60 * 16, 270 * 16); Последние два аргумента drawPie() выражаются в 1/16 долях градуса.

(а) Эллипс.


(б) Сегмент круга.


(в) Кривая Безье.


Рисунок 8.5 Примеры геометрических фигур.
И наконец код, который рисует кривую Безье, показанную на рисунке 8.5(в): QPainter painter(this); QPointArray points(4); points[0] = QPoint(20, 80); points[1] = QPoint(50, 20); points[2] = QPoint(80, 20); points[3] = QPoint(120, 80); painter.setPen(QPen(black, 3, SolidLine)); painter.drawCubicBezier(points); Текущее состояние QPainter может быть сохранено на стеке, вызовом save() и восстановлено со стека, вызовом restore(). Это может потребоваться в том случае, когда необходимо на время изменить какие либо настройки, а затем восстановить их прежние значения.

Кроме перечисленных выше характеристик (перо, кисть и шрифт), QPainter имеет еще целый ряд параметров настройки:

Рассмотрим подробнее систему координат, которая задается параметрами область просмотра (viewport), окно (window) и матрицей преобразования (world matrix). (В данном случае, термин "окно" не имеет ничего общего с виджетом самого верхнего уровня, а "область просмотра" -- с классом QScrollView.)

Понятия область просмотра и окно тесно связаны между собой. Область просмотра -- это произвольный прямоугольник, заданный физическими координатами. Окно -- описывает тот же самый прямоугольник, но уже в логических координатах. Когда выполняется рисование, то указываются логические координаты, которые затем преобразуются в физические.

По-умолчанию координаты области просмотра и окна совпадают с системой координат физического устройства. Например, если устройство отображения представляет из себя виджет, с размерами 320 X 200, то и область просмотра и окно имеют те же самые размеры. В данном случае логическая и физическая системы координат совпадают.

Подобный механизм дает возможность писать код, который не зависит от размера или разрешения устройства. Конечно же, мы и сами можем выполнять отображение логических координат в физические, но проще доверить эту работу классу QPainter. Например, представим, что нам необходимо работать в системе координат, ограниченной прямоугольником от (-50, -50) до (+50, +50), когда точка с координатами (0, 0) находится в центре прямоугольника. В этом случае можно установить параетры окна следующим образом:

painter.setWindow(QRect(-50, -50, 100, 100)); где первые два аргумента задают координаты верхнего левого угла (-50, -50), последние два аргумента (100, 100)-- ширину и высоту прямоугольника, соответственно. В данном случае, это означает, что логические координаты (-50, -50) соответствуют физическим координатам (0, 0), а логические координаты (+50, +50) -- физическим (320, 200). Изменять параметры области просмотра нет необходимости.

Рисунок 8.6. Преобразование логических координат в физические.


Теперь перейдем к матрице преобразований (world matrix). Она задает набор трансформаций, которые должны быть выполнены в дополнение к преобразованиям логических координат в физические. Это позволяет выполнять изменение масштаба, вращение и сдвиг рисуемых элементов. Например, если необходимо нарисовать текст под углом 45 градусов, то можно написать следующий код: QWMatrix matrix; matrix.rotate(45.0); painter.setWorldMatrix(matrix); painter.drawText(rect, AlignCenter, tr("Revenue")); Здесь логические координаты, передаваемые в drawText(), сначала подвергаются трансформации, а затем отображаются в физические координаты.

Если указывается несколько трансформаций, то они применяются в порядке следования в исходном коде программы. Например, допустим, что необходимо повернуть изображение относительно точки с координатами (10, 20). Для этого можно задать следующий порядок трансформаций: сдвинуть окно так, чтобы центр вращения переместился в координаты (0, 0), повернуть изображение и затем выполнить обратный сдвиг:

QWMatrix matrix; matrix.translate(-10.0, -20.0); matrix.rotate(45.0); matrix.translate(+10.0, +20.0); painter.setWorldMatrix(matrix); painter.drawText(rect, AlignCenter, tr("Revenue")); Более простой способ -- воспользоваться методами класса QPainter -- translate(), scale(), rotate() и shear(): painter.translate(-10.0, -20.0); painter.rotate(45.0); painter.translate(+10.0, +20.0); painter.drawText(rect, AlignCenter, tr("Revenue")); Но если необходимо воспользоваться одним и тем же набором трансформаций несколько раз подряд, то вариант с QWMatrix даст значительный выигрыш по времени.

При необходимости, матрицу преобразований можно сохранить вызовом saveWorldMatrix() и затем восстановить вызовом restoreWorldMatrix().

Рисунок 8.7. Внешний вид виджета OvenTimer.


С целью демонстрации использования преобразований, рассмотрим код виджета OvenTimer (таймер электропечи), который изображен на рисунке 8.7. Этот компонент моделирует поведение обычного таймера, которыми раньше, до появления цифровых часов и таймеров, снабжались электропечи. Пользователь может щелкнуть по риске на лимбе таймера, чтобы установить время ожидания, после чего ручка таймера начнет вращаться против часовой стрелки и по достижении нулевой отметки OvenTimer выдаст сигнал timeout(). class OvenTimer : public QWidget { Q_OBJECT public: OvenTimer(QWidget *parent, const char *name = 0); void setDuration(int secs); int duration() const; void draw(QPainter *painter); signals: void timeout(); protected: void paintEvent(QPaintEvent *event); void mousePressEvent(QMouseEvent *event); private: QDateTime finishTime; QTimer *updateTimer; QTimer *finishTimer; }; Класс OvenTimer порожден от класса QWidget и перекрывает два виртуальных метода предка: paintEvent() и mousePressEvent(). #include <qpainter.h> #include <qpixmap.h> #include <qtimer.h> #include <cmath> using namespace std; #include "oventimer.h" const double DegreesPerMinute = 7.0; const double DegreesPerSecond = DegreesPerMinute / 60; const int MaxMinutes = 45; const int MaxSeconds = MaxMinutes * 60; const int UpdateInterval = 10; OvenTimer::OvenTimer(QWidget *parent, const char *name) : QWidget(parent, name) { finishTime = QDateTime::currentDateTime(); updateTimer = new QTimer(this); finishTimer = new QTimer(this); connect(updateTimer, SIGNAL(timeout()), this, SLOT(update())); connect(finishTimer, SIGNAL(timeout()), this, SIGNAL(timeout())); } В конструкторе создаются два объекта QTimer: updateTimer -- для обновления изображения виджета, и finishTimer -- для выдачи сигнала timeout(), по достижении нулевой отметки. void OvenTimer::setDuration(int secs) { if (secs > MaxSeconds) secs = MaxSeconds; finishTime = QDateTime::currentDateTime().addSecs(secs); updateTimer->start(UpdateInterval * 1000, false); finishTimer->start(secs * 1000, true); update(); } Функция setDuration() устанавливает продолжительность действия таймера в секундах. Аргумент false, передаваемый в функцию dateTimer->start() сообщает Qt о том, что это таймер с многократным срабатыванием. Период срабатывания таймера равен 10 секундам. Таймер finishTimer должен сработать всего один раз, поэтому в функцию start(), этого объекта, передается аргумент true. Конечное время работы таймера вычисляется сложением текущего времени, которое мы получаем вызовом QDateTime::currentDateTime() и времени ожидания.

Переменная finishTime имеет тип QDateTime, который в Qt отвечает за хранение даты и времени. Объекты этого типа становятся просто незаменимы в ситуациях, когда в отмеряемый интервал времени попадает граница суток.

int OvenTimer::duration() const { int secs = QDateTime::currentDateTime().secsTo(finishTime); if (secs < 0) secs = 0; return secs; } Функция duration() возвращает число секунд, оставшихся до конца работы таймера. void OvenTimer::mousePressEvent(QMouseEvent *event) { QPoint point = event->pos() - rect().center(); double theta = atan2(-(double)point.x(), -(double)point.y()) * 180 / 3.14159265359; setDuration((int)(duration() + theta / DegreesPerSecond)); update(); } Когда пользователь щелкает по лимбу таймера, вычисляется новый интервал действия таймера. Затем в очередь ставится событие "paint". Теперь, на вершине будет находиться выбранная пользователем риска. void OvenTimer::paintEvent(QPaintEvent *) { QPainter painter(this); int side = QMIN(width(), height()); painter.setViewport((width() - side) / 2, (height() - side) / 2, side, side); painter.setWindow(-50, -50, 100, 100); draw(&painter); } В обработчике paintEvent() устанавливается область просмотра (viewport), которая по своим размерам является наибольшей квадратной областью, которую можно разместить в виджете, а затем настраивается окно -- прямоугольник (-50, -50, 100, 100), с размерами 100 X 100. Макрос QMIN() возвращает наименьшее из двух аргументов.

Рисунок 8.8. Внешний вид виджета OvenTimer с различными размерами.


Если область просмотра (viewport) не сделать квадратной, то лимб таймера будет рисоваться в виде эллипса, когда форма фиджета будет далека от квадратной. Вообще, во избежание подобного рода деформаций, необходимо задавать настройки области просмотра и окна с одинаковыми отношениями сторон.

Размеры окна (-50, -50, 100, 100) выбирались из следующих соображений:

В данной ситуации, выбор параметров (-50, -50, 100, 100) окна выглядит более предпочтительно, чем скажем (-5, -5, 10, 10) или (-2000, -2000, 4000, 4000).

Теперь перейдем к функции draw():

void OvenTimer::draw(QPainter *painter) { static const QCOORD triangle[3][2] = { { -2, -49 }, { +2, -49 }, { 0, -47 } }; QPen thickPen(colorGroup().foreground(), 2); QPen thinPen(colorGroup().foreground(), 1); painter->setPen(thinPen); painter->setBrush(colorGroup().foreground()); painter->drawConvexPolygon(QPointArray(3, &triangle[0][0])); Рисование виджета начинается с маленького треугольника, который обозначает нулевую позицию вверху. Треугольник задается тремя, жестко зашитыми парами координат. Собственно рисование производится функцией drawConvexPolygon(). Треугольник можно было бы нарисовать функцией drawPolygon(), но если заранее известно, что многоугольник выпуклый, то вы можете сэкономить несколько микросекунд, за счет использования функции drawConvexPolygon().

Одна из замечательных сторон механизма перехода от логических координат к физическим состоит в том, что мы можем жестко зашивать координаты точек в исходный код и при этом получать неплохие результаты, при изменении размеров виджета.

painter->setPen(thickPen); painter->setBrush(colorGroup().light()); painter->drawEllipse(-46, -46, 92, 92); painter->setBrush(colorGroup().mid()); painter->drawEllipse(-20, -20, 40, 40); painter->drawEllipse(-15, -15, 30, 30); Далее рисуются внешний и два внутренних круга. Внешний круг заполняется цветом "light" (обычно -- белый), Внутренние круги заполняются цветом "mid" (обычно -- серый). int secs = duration(); painter->rotate(secs * DegreesPerSecond); painter->drawRect(-8, -25, 16, 50); for (int i = 0; i <= MaxMinutes; ++i) { if (i % 5 == 0) { painter->setPen(thickPen); painter->drawLine(0, -41, 0, -44); painter->drawText(-15, -41, 30, 25, AlignHCenter | AlignTop, QString::number(i)); } else { painter->setPen(thinPen); painter->drawLine(0, -42, 0, -44); } painter->rotate(-DegreesPerMinute); } } Затем рисуются рукоятка и риски на лимбе. Напротив каждой пятой риски рисуется число, обозначающее количество минут. Функция rotate() вызывается для того, чтобы повернуть систему координат. В начальный момент, риска с отметкой "0" находилась вверху, теперь же она переместилась в точку, координаты которой зависят от оставшегося до срабатывания времени. Рукоятка рисуется после выполнения поворота, поскольку ее ориентация зависит от угла поворота.

В цикле for, по краю внешнего круга рисуются риски, а под ними -- числа, обозначающие количество минут, с шагом 5. В конце каждой итерации выполняется поворот системы координат по часовой стрелке на 7 градусов, что соответствует одной минуте. Таким образом, каждая следующая риска будет рисоваться на своем месте, хотя координаты в drawLine() и drawText() задаются одни и те же.

Тут есть еще одна проблема, которую мы не учли -- мерцание. Виджет перерисовывается целиком каждые 10 секунд, что становится причиной появления эффекта подмаргивания изображения. Чтобы избавиться от нее, добавим двойную буферизацию. Для этого нужно передать родительскому конструктору флаг WNoAutoErase и изменить paintEvent() следующим образом:

void OvenTimer::paintEvent(QPaintEvent *event) { static QPixmap pixmap; QRect rect = event->rect(); QSize newSize = rect.size().expandedTo(pixmap.size()); pixmap.resize(newSize); pixmap.fill(this, rect.topLeft()); QPainter painter(&pixmap, this); int side = QMIN(width(), height()); painter.setViewport((width() - side) / 2 - event->rect().x(), (height() - side) / 2 - event->rect().y(), side, side); painter.setWindow(-50, -50, 100, 100); draw(&painter); bitBlt(this, event->rect().topLeft(), &pixmap); } На этот раз все рисование производится в буфере. Сначала устанавливается размер будущего изображения, в соответствии с размером области, которую необходимо перерисовать. Затем настраиваются область просмотра и окно таким образом, что сам процесс рисования проходит точно так же, как и раньше. Благодаря этому нам не надо вносить изменения в функцию draw(). В завершение обработки события "paint", готовый буфер переносится на поверхность виджета, функцией bitBlt().

Очень похоже на то, что мы описывали в разделе Двойная буферизация, но с одним важным отличием: в Главе 5, для выполнения сдвига, мы пользовались функцией translate(), теперь же, мы вычитаем координаты левого верхнего угла прямоугольника, требующего перерисовки, при настройке области просмотра. Использование translate() здесь было бы не очень удобным, поскольку преобразование должно быть выражено в логических координатах, в то время как событие поставляется с координатами физическими.