Целью этого документа, как и первого (и частично второго) задания в курсе "Машинная графика" для студентов второго курса ВМиК МГУ является ознакомление новичков с тем, как устроена 2D графика в Windows и как с помощью имеющихся средств можно делать простые, но полезные вещи.
Чего мы коснемся:
Не стану углубляться в теорию строения 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, каждая из которых описывает одну графическую функцию. В отличие от растровых файлов, хранящих графическую информацию непосредственно в виде массива пикселов, метафайл ее хранит в виде последовательности команд, которая создает результирующий рисунок.
Для вывода графической информации существует набор функций, которые можно разделить на несколько категорий:
Существует отдельная категория функций работы с DC по переключению режимов и установке параметров вывода графической информации. Часть из них устанавливается напрямую через определенные функции (например, SetBkColor), часть - с помощью специальных графических объектов:
Работа с графическими объектами производится с помощью их дескрипторов (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 изначально (см. пример выше).
Для того чтобы выводить графику в определенное окно вашего приложения нужно сделать буквально следующее:
Получить дескриптор DC, связанный с окном, в которое вы собираетесь рисовать с помощью функции GetDC(). Нарисовать все, что вы хотите, с помощью функций DC и в конце "освободить" контекст с помощью функции ReleaseDC().
Пример:
//hwnd - дескриптор окна, в которое будем рисовать
HDC hdc;
hdc = GetDC(hwnd);
if ¯(hdc)
{
// рисуем что требуется
...
// освобождаем контекст
ReleaseDC(hwnd, hdc);
}
else
{
// обработка ошибки получения контекста
}
Иным образом производится получение/освобождение дескриптора DC при обработке сообщения 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
{
// обработка ошибки получения контекста
}
С каждым 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
{
// обработка ошибки получения контекста
}
Пользуясь базовыми функциями 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, который был создан вместе с контекстом, в противном случае произойдет утечка ресурсов.
Есть как минимум два способа. Первый - это получить указатель на пиксели растрового изображения (вариант как это сделать см. секцию 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.
Для облегчения программирования под WinAPI было создано некоторое количество объектно-ориентированных надстроек для него. В числе самых распространенных - Microsoft Foundation Class Library (MFC) от Microsoft (используемая в MS Visual Studio) и Visual Components Library (VCL) от Borland (используемая в Delphi и C++ Builder). Обе этих библиотеки уже достаточно пожилые, но тем не менее все еще широко распространенные.
С появлением этих (и других) надстроек, люди крайне редко по-прежнему программируют чисто под WinAPI (что в общем-то понятно).
Для облегчения работы с функциями и структурами 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)
}
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, прилагаемых к данному тексту.
При написании фильтров для изображения требуется способ доступа к отдельным пикселям. Самый простой способ - сделать это с помощью функций GetPixel/SetPixel в WinAPI и MFC и с помощью двумерного массива TCanvas->Pixels в VCL. Однако так поступать не стоит, поскольку такой способ является чрезвычайно медленным.
При работе с функциями GDI напрямую, наиболее удобным представляется создание объекта bitmap, к пикселям которого можно обращаться напрямую. Делается это с помощью функции CreateDIBSection. Одним из выходных параметров этой функции является указатель на переменную, куда при создании bitmap будет помещен указатель на массив пикселей - ppvBits. Запомнив этот указатель, приложение получает прямой доступ к пикселям изображения. Обычно использующиеся true color изображения с глубиной цвета 24 bit хранят данные попиксельно в виде массива троек `BGR' (каждый пиксель - три байта).
Адрес пикселя с координатами (x, y) для изображений такого типа
рассчитывается следующим образом:
| (1) |
Здесь iBytesPerLine - это длина строки изображения в байтах,
которая отнюдь не всегда равна ширине изображения, умноженной на
три. Для увеличения производительности работы с изображением
адреса начал строк выравниваются по границе процессорного слова (4
байта), поэтому если ширина, умноженная на 3, не кратна четырем,
каждая из строк дополняется несколькими дополнительными байтами .
Рассчитать длину строки в байтах можно по следующей формуле:
| (2) |
Именно таким образом быстрый доступ к пикселям изображения реализован в классе DSimpleBitmap в примере MFC_GML3.
Для того чтобы получить прямой доступ к указателю на пикселы изображения, хранящегося в TBitmap, нужно использовать свойство ScanLine. Это массив указателей на строки пикселей изображения.
Доступ к пикселю с координатами (x, y) осуществляется следующим образом:
// pBitmap - указатель на TBitmap обрабатываемого изображения
pBitmap->ScanLine[y][x * 3]
Формат хранящихся в изображении данных задается свойством PixelFormat объекта TBitmap. Для полноцветных изображений (PixelFormat = pf24bit) каждому пикселю соответствует три байта, задающие интенсивности каждого из цветовых каналов - 'BGR'.
Как подобным образом реализовать фильтрацию изображения, показано в примерах SDIApp и DelphiBasis.
Комментарии
Спасибо за информацию! ОЧень помогло
Просто, грамотно, понятно! Thanks!
Очени неплохо!
Просто, понятно - действительно хорошо.
Отправить комментарий