Курсы лаборатории компьютерной графики
Обязательный полугодовой курс ВМиК МГУ
     

Краткое неформальное введение в графику Windows

Вежневец Владимир1

Аннотация

Целью этого документа является ознакомление новичков с тем, как устроена 2D графика в Windows и как с помощью имеющихся средств можно делать простые, но полезные вещи.

К данному тексту прилагаются три программки с исходным кодом под Visual C++ 6.0, Delphi 5 и CBuilder 5, в которых дан "скелет" приложения умеющего загрузить, показать, обработать и сохранить изображение в формате bmp.

Содержание

1. Цель этого документа
2. Устройство графики в Windows
    2.1 Graphics Device Interface и Device Context
    2.2 Как рисовать в Device Context?
    2.3 Как рисовать в окно приложения?
    2.4 Когда рисовать в окно приложения? WM_PAINT - что это?
    2.5 Как БЫСТРО рисовать в Device Context?
    2.6 Как загрузить и вывести на экран изображение?
    2.7 Как нарисовать что-либо на изображении?
Надстройки над GDI
    3.1 MFC надстройка над GDI
    3.2 VCL надстройка над GDI
4. Как (относительно) быстро обрабатывать изображения?
    4.1 Быстрый способ доступа к пикселям в GDI и MFC
    4.2 Быстрый способ доступа к пикселям в VCL

1  Цель этого документа

Целью этого документа, как и первого (и частично второго) задания в курсе "Машинная графика" для студентов второго курса ВМиК МГУ является ознакомление новичков с тем, как устроена 2D графика в Windows и как с помощью имеющихся средств можно делать простые, но полезные вещи.

Чего мы коснемся:

  • Устройство графики в Windows;
  • Как рисовать простые вещи (линии, геометрический фигуры, текст) с помощью функций WinAPI 2;
  • Как загрузить, отобразить и обработать изображение с помощью функций WinAPI;
  • MFC надстройка над WinAPI;
  • VCL надстройка над WinAPI;
В качестве приложения к данному тексту выступают три программки с исходным кодом под Visual C++ 6.0 (MFC_GML3), Delphi 5 (DelphiBasis) и CBuilder 5 (SDIApp), в которых дан "скелет" приложения, умеющего загрузить, показать, обработать и сохранить изображение в формате bmp.

2  Устройство графики в Windows

2.1  Graphics Device Interface и Device Context

Не стану углубляться в теорию строения Windows и ее графической подсистемы (литературы на эту тему написано вполне достаточно), постараюсь коротко изложить некий минимум знаний, который понадобится при программировании простейшей графики в Windows. При этом я постараюсь также дать понимание что и как устроено (пускай на простом уровне).

Во-первых, в Microsoft Windows существует несколько средств для вывода графической информации, включая DirectDraw, OpenGL, GDI и т.д. Мы рассмотрим GDI (Graphics Device Interface) - подсистему Windows, ответственную за вывод графики и текста на дисплей и принтер. Именно она занимается выводом большинства "окошек", которые и составляют то, что видит пользователь Windows на экране. Она является базовым и, пожалуй, простейшим способом вывода графики в Windows.

С графикой Windows с помощью GDI неразрывно связано понятия контекста устройства (device context). Контекст устройства (DC) - это структура данных, содержащая информацию о параметрах и атрибутах вывода графики на устройство (например, дисплей или принтер). Такая информация, в частности, включает в себя: палитру устройства, определяющую набор доступных цветов; параметры пера для черчения линий; параметры кисти для закраски и заливки; параметры шрифта, использующегося для вывода текста.

В GDI существуют пять типов контекста устройства - связанный с дисплеем (Display DC), принтером (Printer DC), контекст виртуального устройства в памяти (Memory DC), контекст метафайла (Metafile DC) и специальный вид контекста - информационный (Information DC).

Первые четыре типа контекста устройства - display, printer, memory и metafile предоставляют унифицированный интерфейс для вывода графической информации на разнотипные устройства, освобождая приложение (и его разработчика) от необходимости заботится о том, куда именно производится вывод графики. Информационный контекст для вывода графики не используется, он служит исключительно для получения информации о параметрах и поддерживаемых режимах устройства, с которым связан.

В чем отличие первых четырех типов контекста? Это можно понять из их названий - Display DC служит для вывода на экран, Printer DC для печати на принтер или графопостроитель, Memory DC служит для создания растровых изображений в памяти с возможностью быстрого их копирования в другие типы контекстов (и обратно), Metafile DC нужен для вывода графики в метафайл. Метафайл - это хранилище последовательности команд GDI, каждая из которых описывает одну графическую функцию. В отличие от растровых файлов, хранящих графическую информацию непосредственно в виде массива пикселов, метафайл ее хранит в виде последовательности команд, которая создает результирующий рисунок.

2.2  Как рисовать в Device Context?

Для вывода графической информации существует набор функций, которые можно разделить на несколько категорий:

  • Методы рисования линий: LineTo, MoveTo, Polyline, Arc, ArcTo, PolyBezier, и др.
  • Методы рисования замкнутых фигур: Ellipse, Rectangle, Polygon, Pie, Chord и др.
  • Методы вывода текста: TextOut, DrawText и т.д.
  • Функции работы с растровым изображением: GetPixel, SetPixel, FloodFill, BitBlt и т.д.

Существует отдельная категория функций работы с DC по переключению режимов и установке параметров вывода графической информации. Часть из них устанавливается напрямую через определенные функции (например, SetBkColor), часть - с помощью специальных графических объектов:

перо (pen)
- задает режим вывода линий (цвет, толщина, стиль);
кисть (brush)
- регулирует режим закраски фигур (цвет, стиль);
шрифт (font)
- задает свойства шрифта, которым выводится текст;
палитра (palette)
- задает набор используемых в DC цветов;
область (region)
- используются для задания clipping regions - областей отсечения, вне которых вывод графики блокируется.

Работа с графическими объектами производится с помощью их дескрипторов (handles) - HDC, HPEN, HBRUSH, HFONT и т.д. Создание и удаление объектов производится с помощью соответствующих функций - например, объект pen создается с помощью CreatePen, удаляется с помощью DeleteObject. Режимы, задающиеся через графические объекты, переключаются с помощью создания новых объектов и указания контексту (DC) использовать их для вывода графики. Это делается помощью функции SelectObject:

//hdc - дескриптор контекста устройства
HPEN hWhitePen, hBlackPen, hOldPen;
HBRUSH hBlackBrush, hOldBrush;

hWhitePen = CreatePen(PS_SOLID, 1, RGB(255, 255, 255));
hBlackPen = CreatePen(PS_SOLID, 1, RGB(0, 0, 0));
hBlackBrush = CreateSolidBrush(RGB(0, 0, 0));


 // нарисовать белый квадрат
hOldPen = SelectObject(hdc, hWhitePen);
MoveTo(hdc, 10, 10);
LineTo(hdc, 100, 10);
LineTo(hdc, 100, 100);
LineTo(hdc, 10, 100);
LineTo(hdc, 10, 10);


 // нарисовать черную окружность
SelectObject(hdc, hBlackPen);
hOldBrush = SelectObject(hdc, hBlackBrush);
Ellipse(hdc, 10, 10, 100, 100);


 // вернуть старый объекты pen и brush в DC
SelectObject(hdc, hOldPen);
SelectObject(hdc, hOldBrush);


 // освободить ресурсы
DeleteObject(hWhitePen);
DeleteObject(hBlackPen);
DeleteObject(hBlackBrush);

При выборе нового объекта через SelectObject в качестве возвращаемого значения передается дескриптор объекта, бывшего в использовании в DC раньше. Нужно иметь ввиду, что все создаваемые объекты нужно не забывать удалять их после использования. Более того, сам DC всегда создается с некоторыми объектами по умолчанию и при использовании определенных пользователем объектов через SelectObject нужно в конце работы произвести select объектов, которые были в DC изначально (см. пример выше).

2.3  Как рисовать в окно приложения?

Для того чтобы выводить графику в определенное окно вашего приложения нужно сделать буквально следующее:

Получить дескриптор DC, связанный с окном, в которое вы собираетесь рисовать с помощью функции GetDC(). Нарисовать все, что вы хотите, с помощью функций DC и в конце "освободить" контекст с помощью функции ReleaseDC().

Пример:

//hwnd - дескриптор окна, в которое будем рисовать
HDC hdc;

hdc = GetDC(hwnd);
if ¯(hdc)
{
   // рисуем что требуется
   ...

   // освобождаем контекст
   ReleaseDC(hwnd, hdc);
}
else
{
   // обработка ошибки получения контекста
}

Иным образом производится получение/освобождение дескриптора DC при обработке сообщения WM_PAINT - об этом в следующем разделе.

2.4  Когда рисовать в окно приложения? WM_PAINT - что это?

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

Дело в том, что Windows не хранит содержимое клиентской части окна. К клиентской части окна относится ВСЕ, кроме заголовка окна и управляющих элементов (controls): меню, панелей инструментов (toolbar), кнопок и т.д. Приложение само должно позаботиться о том, чтобы отрисовывать свои данные в клиентской области, Windows лишь посылает ему уведомление когда это нужно сделать. Делается это посредством посылки окну сообщения WM_PAINT.

Все необходимые действия по полной перерисовке информации клиентской части окна должны вызываться при обработке события WM_PAINT. Важным понятием при обработке этого сообщения является invalid rectangle. Windows определяет invalid rectangle как наименьшую прямоугольную часть окна, которая была "испорчена" и должна быть перерисована заново. Когда система обнаруживает invalid rectangle в клиентской области окна, она генерирует сообщение WM_PAINT. В ответ на сообщение окно может получить структуру PAINTSTRUCT, которая среди прочего содержит координаты invalid rectangle. Это может пригодиться, если есть желание перерисовывать не все окно, а только ту область, что требуется.

При обработке WM_PAINT должна быть вызвана функция BeginPaint, которая снова делает invalid rectangle `нормальным'. Также BeginPaint возвращает дескриптор DC, который должен быть использован для перерисовки клиентской части окна. Нужно иметь в виду, что при обработке WM_PAINT дескриптор DC окна должен быть получен именно с использованием BeginPaint, а освобожден EndPaint, в то время как во всех других случаях отрисовки нужно использовать другие функции (например, GetDC/ReleaseDC). Если invalid rectangle не делается "нормальным" во время обработки этого события (с помощью BeginPaint или ValidateRect), Windows будет слать WM_PAINT окну постоянно.

Пример обработки WM_PAINT:

//hwnd - дескриптор окна, в которое будем рисовать
HDC         hdc;
PAINTSTRUCT PaintStruct;

hdc = BeginPaint(hwnd, &PaintStruct);
if ¯(hdc)
{
   // рисуем что требуется
   ...

   // освобождаем контекст
   EndPaint(hwnd, &PaintStruct);
}
else
{
   // обработка ошибки получения контекста
}

2.5  Как БЫСТРО рисовать в Device Context?

С каждым DC, предназначенным для графического вывода, связан графический объект bitmap (растровое изображение), который хранит массив пикселей, выводимых на устройство. Для того, чтобы быстро переместить графические данные с одного контекста на другой, можно не повторять все действия по отрисовке, а просто скопировать данные связанного с контекстом bitmap. Для этого даны специальные функции быстрого копирования пикселей (BitBlt, StretchBlt).

Зачем это может быть нужно? Дело в том, что если вы часто рисуете достаточно сложную изменяющуюся картинку средствами GDI, сами операции рисования начинают занимать заметное для пользователя время и возникает неприятный эффект мерцания изображения - когда часть картинки уже перерисовалась, а часть еще осталась старой. Для того, чтобы избежать подобного эффекта новая картинка может создаваться в виртуальном DC в памяти, и потом быстро переносится на экран функциями копирования bitmap.

Пример:

// hdc - дескриптор контекста устройства для вывода
// iWidth, iHeight - размеры окна вывода
HDC hMemDC;

hMemDC = CreateCompatibleDC(hdc);
if ¯(hMemDC)
{
  // рисуем все что требуется
  ...

  // быстро копируем результат отрисовки
  BitBlt(hdc, 0, 0, iWidth, iHeight, hMemDC, 0, 0, SRCCOPY);
  // освобождаем контекст
  DeleteDC(hMemDC);
}
else
{
  // обработка ошибки получения контекста
}

2.6  Как загрузить и вывести на экран изображение?

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

В принципе, если вы не собираетесь выводить загружаемое растровое изображение на экран (а, скажем, только обрабатывать и сохранять), то его можно хранить в совершенно произвольных собственных структурах данных. Однако, если вы хотите иметь возможность быстро вывести ваше изображение на экран, или рисовать в нем средствами GDI, придется хранить его определенным образом. Потребуется создать графический объект bitmap, соответствующий параметрам файла bmp, и загрузить в него данные из файла (пиксели). Пример, как это сделать, содержится в классе DSimpleBitmap в примере MFC_GML3.

Для того, чтобы уметь быстро выводить загруженное изображение на экран, требуется сделать следующее - с помощью функции SelectObject привязать к созданному заранее memory DC загруженный bitmap (вместо default bitmap, создающегося вместе с контекстом) и затем функцией копирования битов вывести в дисплейный контекст, связанный с вашим окном. Пример:


// hdc - дескриптор контекста устройства для вывода
// iWidth, iHeight - размеры окна вывода
// hBitmap - дескриптор изображения для отрисовки
HDC     hMemDC;
HBITMAP hOldBitmap;

hMemDC = CreateCompatibleDC(hdc);
if (hMemDC)
{
   // рисуем все что требуется
   hOldBitmap = SelectObject(hMemDC, hBitmap);
   // копируем биты
   BitBlt(hdc, 0, 0, iWidth, iHeight, hMemDC, 0, 0, SRCCOPY);
   // возвращаем старый bitmap
   SelectObject(hMemDC, hOldBitmap);
   // освобождаем контекст
   DeleteDC(hMemDC);
}
else
{
   // обработка ошибки получения контекста
}

Не забудьте уничтожить все временные объекты, которые создавались (в данном случае - это memory DC). Не забудьте также перед тем как будете уничтожать memory DC, выбрать в него (через SelectObject) объект bitmap, который был создан вместе с контекстом, в противном случае произойдет утечка ресурсов.

2.7  Как нарисовать что-либо на изображении?

Есть как минимум два способа. Первый - это получить указатель на пиксели растрового изображения (вариант как это сделать см. секцию 4.1) и менять их напрямую. Второй - это рисовать на изображении с помощью функций GDI. Для реализации второго варианта нужно создать DC, связать с ним bitmap, на котором хотите рисовать, и затем использовать стандартные функции вывода графики. Пример:

// hdc - дескриптор некоторого контекста устройства
// hBitmap - дескриптор изображения
HBITMAP hOldBitmap;
// связываем bitmap с контекстом
hOldBitmap = SelectObject(hdc, hBitmap);
// рисуем круг 
Ellipse(hdc, 10, 10, 100, 100);
// возвращаем старый bitmap
SelectObject(hdc, hOldBitmap);

Имейте в виду, объект bitmap может быть одновременно связан только с одним DC.

3  Надстройки над GDI

Для облегчения программирования под WinAPI было создано некоторое количество объектно-ориентированных надстроек для него. В числе самых распространенных - Microsoft Foundation Class Library (MFC) от Microsoft (используемая в MS Visual Studio) и Visual Components Library (VCL) от Borland (используемая в Delphi и C++ Builder). Обе этих библиотеки уже достаточно пожилые, но тем не менее все еще широко распространенные.

С появлением этих (и других) надстроек, люди крайне редко по-прежнему программируют чисто под WinAPI (что в общем-то понятно).

3.1  MFC надстройка над GDI

Для облегчения работы с функциями и структурами GDI в MFC создан набор классов, являющихся обертками для WinAPI структур и дескрипторов.

К их числу относятся CDC, CPen, CBitmap, CFont, CBrush и т.д. Работа с ними практически идентична работе с дескрипторами этих объектов, но несколько удобнее.

Что значит обертками? Это значит, что CPen внутри себя содержит HPEN (доступный как свойство класса) и просто берет на себя некоторые заботы по его созданию, удалению и работе с ним. Похожим образом организованы все обертки.

CDC - это абстрактный базовый класс, у которого есть несколько реализаций - CPaintDC, CClientDC, CWindowDC, CMetaFileDC, каждая должна использоваться в определенных ситуациях.

Работа с графическими фукнциями GDI с использованием MFC несколько упрощается (сравните с примером в разделе 2.2):

//pDC - указатель на CDC (обертку дескриптора контекста устройства)
CPen  WhitePen(PS_SOLID, 1, RGB(255, 255, 255)),
      BlackPen(PS_SOLID, 1, RGB(0, 0, 0)),
     *pOldPen;


CBrush  BlackBrush(RGB(0, 0, 0)),
       *pOldBrush;

// нарисовать белый квадрат
pOldPen = pDC->SelectObject(&WhitePen);
pDC->MoveTo(10, 10);
pDC->LineTo(100, 10);
pDC->LineTo(100, 100);
pDC->LineTo(10, 100);
pDC->LineTo(10, 10);

// нарисовать черную окружность
pDC->SelectObject(&BlackPen);
pOldBrush = pDC->SelectObject(&BlackBrush);
pDC->Ellipse(10, 10, 100, 100);

// вернуть старый объекты pen и brush в DC
pDC->SelectObject(pOldPen);
pDC->SelectObject(pOldBrush);

// ресурсы будут освободены при уничтожении объектов CPen

К сожалению, никаких средств для загрузки bmp файлов в CBitmap и для простой отрисовки CBitmap в DC в MFC не предоставлено - приходится пользоваться теми же средствами, что и при работе с WinAPI. Обработка WM_PAINT производится практически идентично, за исключением того, что в MFC существует специальный тип CPaintDC, в конструктор и деструктор которого инкапсулированы (встроены) вызовы BeginPaint/EndPaint. Обработка события выглядит следующим образом:

void CImageView::OnPaint()
{  ¯
 // Подразумевается, что это функция-член окна,
тогда this
 // указывает на CWnd - обертку дескриптора данного окна
 CPaintDC dc(this);

 // рисуем что требуется
 ...

 // контекст освободится сам при выходе из функции
 // (при уничтожении объекта dc)
}

3.2  VCL надстройка над GDI

Visual Components Library (VCL) от Borland делает гораздо более длинный шаг в сторону упрощения работы с графикой.

В этой библиотеке введен класс TCanvas, также являющийся оберткой для HDC (HDC доступен через свойство Handle), но представляющий более высокоуровневый интерфейс для работы с графикой. Переключение режимов производится путем модификации свойств класса TCanvas - Pen, Font, Brush, TextFlags и т.д., что делает переключение режимов рисования значительно проще и прозрачнее и избавляет разработчика от чехарды с SelectObject/GetCurrentObject/DeleteObject. Операции GetPixel/PutPixel реализованы как доступ к двумерному массиву Pixels (что не делает работу с ними более быстрой).

Canvas связан со всеми компонентами VCL, у которых есть клиентская часть, а также с классом TBitmap. Стандартные компоненты Windows такие как кнопки, списки и т.д. Canvas не имеют, так как их полностью отрисовывает Windows. Рисование на Canvas происходит путем вызова соответствующих функций-членов. Пример (сравните с 2.2, 3.1):

  // AppForm - класс окна (TForm), в котором мы собираемся
  // рисовать

  // Нарисовать белый квадрат
  AppForm->Canvas->Pen->Color = clWhite;

  AppForm->Canvas->MoveTo(10, 10);
  AppForm->Canvas->LineTo(100, 10);
  AppForm->Canvas->LineTo(100, 100);
  AppForm->Canvas->LineTo(10, 100);
  AppForm->Canvas->LineTo(10, 10);

  // Нарисовать черную окружность
  AppForm->Canvas->Pen->Color = clBlack;
  AppForm->Canvas->Brush->Color = clBlack;

  AppForm->Canvas->Ellipse(10, 10, 100, 100);

Обработка сообщения WM_PAINT происходит без дополнительной заботы о создании DC особым образом (CPaintDC или BeginPaint), просто нужно работать с Canvas перерисовываемого объекта.

Быстрое копирование из Canvas в Canvas осуществляется путем использования функции CopyRect, аналогичной BitBlt, StretchBits.

Загрузка изображения из файла и отображение на экране с использованием VCL значительно упрощается. Растровые изображения, иконки и метафайлы хранятся в соответствующих классах (TBitmap, TIcon, TMetaFile) - наследниках базового класса изображений TGraphic. Для облегчения работы с этими классами в VCL добавлен класс-контейнер TPicture, который может работать с любым из наследников TGraphic, реализуя функциональность загрузки/сохранения и копирования объекта в буфер обмена (clipboard).

В VCL существует еще один класс, облегчающий вывод графического изображения в окно - TImage. TImage - это компонент, содержащий некоторые свойства и параметры, задающие как именно будет отрисовываться изображение в окне приложения. Само изображение хранится в свойстве Picture класса TImage. Стоит иметь в виду, что при использовании TImage VCL полностью берет на себя обработку сообщения WM_PAINT. То есть все, что нарисовано на Canvas компонента TImage, автоматически отображается на экране, когда это требуется - достаточно нарисовать все что нужно один раз.

Загрузка и отображение растровой картинки с помощью TImage показана в примере SDIApp и DelphiBasis, прилагаемых к данному тексту.

4  Как (относительно) быстро обрабатывать изображения?

При написании фильтров для изображения требуется способ доступа к отдельным пикселям. Самый простой способ - сделать это с помощью функций GetPixel/SetPixel в WinAPI и MFC и с помощью двумерного массива TCanvas->Pixels в VCL. Однако так поступать не стоит, поскольку такой способ является чрезвычайно медленным.

4.1  Быстрый способ доступа к пикселям в GDI и MFC

При работе с функциями GDI напрямую, наиболее удобным представляется создание объекта bitmap, к пикселям которого можно обращаться напрямую. Делается это с помощью функции CreateDIBSection. Одним из выходных параметров этой функции является указатель на переменную, куда при создании bitmap будет помещен указатель на массив пикселей - ppvBits. Запомнив этот указатель, приложение получает прямой доступ к пикселям изображения. Обычно использующиеся true color изображения с глубиной цвета 24 bit хранят данные попиксельно в виде массива троек `BGR' (каждый пиксель - три байта).

Адрес пикселя с координатами (x, y) для изображений такого типа рассчитывается следующим образом:
ppvBits + y * iBytesPerLine + x * 3
(1)

Здесь iBytesPerLine - это длина строки изображения в байтах, которая отнюдь не всегда равна ширине изображения, умноженной на три. Для увеличения производительности работы с изображением адреса начал строк выравниваются по границе процессорного слова (4 байта), поэтому если ширина, умноженная на 3, не кратна четырем, каждая из строк дополняется несколькими дополнительными байтами . Рассчитать длину строки в байтах можно по следующей формуле:
iBytesPerLine = (iWidth * 3 + 3) & -4;
(2)

Именно таким образом быстрый доступ к пикселям изображения реализован в классе DSimpleBitmap в примере MFC_GML3.

4.2  Быстрый способ доступа к пикселям в VCL

Для того чтобы получить прямой доступ к указателю на пикселы изображения, хранящегося в TBitmap, нужно использовать свойство ScanLine. Это массив указателей на строки пикселей изображения.

Доступ к пикселю с координатами (x, y) осуществляется следующим образом:

    // pBitmap - указатель на TBitmap обрабатываемого изображения
    pBitmap->ScanLine[y][x * 3]

Формат хранящихся в изображении данных задается свойством PixelFormat объекта TBitmap. Для полноцветных изображений (PixelFormat = pf24bit) каждому пикселю соответствует три байта, задающие интенсивности каждого из цветовых каналов - 'BGR'.

Как подобным образом реализовать фильтрацию изображения, показано в примерах SDIApp и DelphiBasis.


Сноски:

1mailto:vvp@graphics.cs.msu.su
2Windows Application Programming Interface - программный интерфейс системы Windows (набор функций, предоставляемый пользовательским приложениям)


File translated from TEX by TTH, version 3.33.
On 10 Feb 2004, 20:49.
Главная | О курсе | Лекции | Библиотека | Задания | Оценки | FAQs | Форум
  (с) Лаборатория компьютерной графики, 1997-2005
Дизайн: Алексей Игнатенко