Разработка компьютерных игр

A surface represents a linear area of display memory and usually resides in the display memory of the display card…
Документация к Direct3D


Однажды недалеко от моего дома решили возвести некое очень высотное деловое здание, огородили все, конечно... Но ночью подул сильный ветер, какие бывают у нас летом, и значительная часть забора просто упала. М-да, а люди решили возводить небоскреб... Данный пример показателен в том, что нельзя к мелочам относиться несерьезно. Если у вас где-то в чем-то пробелы знаний, то найдите время их восполнить. Это не так трудно, как кажется. Просто ответьте себе на вопрос: за что мы так любим фирменную продукцию? Качественный подход к каждой отдельной детали формирует общее впечатление. К сожалению, в играх пока нет той фирменной стабильности даже у множества крупных разработчиков. Есть глюки, системные вылеты, а иногда и просто продукт может выглядеть незавершенным, ошибки латаются наспех, причем зачастую обычными блокировками. Стремление к идеалу, конечно, обычно ведет затяжным путем в бесконечность, но все же мы говорим и не о лапше быстрого приготовления. В общем, чем больше вопросов профессиональной деятельности для вас будут прозрачными и понятными, тем лучше вы себя будете чувствовать в сегменте геймдева.

Сегодня мы продолжаем нашу беседу — напомним, что остановились в прошлый раз на COM-объектах, которые в рамках нашего использования часто воспринимаются как интерфейсы либо как классы С++. Правда, с одной разницей, ведь мы не можем использовать для их создания и удаления ключевые слова new и delete (об IUnknown и его методах мы подробно поговорили в прошлой части серии). По существу, к COM-интерфейсу нужно относиться как к микроволновой печке, ведь нам не важно, из чего она состоит, как реализованы различные функции, по какому алгоритму идет нагрев — мы просто кладем пищу, устанавливаем мощность, время и даем команду на исполнение. При этом даже не важно, на каком языке говорит пользователь и какими цифрами (римскими или арабскими) обозначены циферблаты. Именно такой подход является ключевым как в объектноориентированном программировании, так и в его более расширенной и современной модели — интерфейсноориентированном. Поэтому на самом деле не очень важно, из какого языка вы пользуетесь тем же DirectX или OpenGL, основная "внутренняя кухня" вас также не должна интересовать, если в этом нет крайней необходимости. Причем они берут на себя множество различных, иногда трудоемких, задач, о которых современный программист и не задумывается — например, автоматическое, то есть самостоятельное, управление памятью. Также напомним, что для обозначения COM-интерфейсов в коде в их названиях предусмотрен префикс "I". Чтобы узнать обо всех COM-интерфейсах в рамках Direct3D с описанием доступных методов, можно воспользоваться документацией к SDK, набрав ключевое слово поиска "D3D Interfaces". Там вы обнаружите список из 22:
. ID3DXFile;
. ID3DXFileData;
. ID3DXFileEnumObject;
. ID3DXFileSaveData;
. ID3DXFileSaveObject;
. IDirect3D9;
. IDirect3DBaseTexture9;
. IDirect3DCubeTexture9;
. IDirect3DDevice9;
. IDirect3DIndexBuffer9;
. IDirect3DPixelShader9;
. IDirect3DQuery9;
. IDirect3DResource9;
. IDirect3DStateBlock9;
. IDirect3DSurface9;
. IDirect3DSwapChain9;
. IDirect3DTexture9;
. IDirect3DVertexBuffer9;
. IDirect3DVertexDeclaration9;
. IDirect3DVertexShader9;
. IDirect3DVolume9;
. IDirect3DVolumeTexture9.

Одного из них мы уже, так или иначе, касались, а именно непосредственно IDirect3D9 для проверки графического адаптера (метод IDirect3D9::GetDeviceCaps). Рассматривать весь этот список подробно пункт за пунктом на самом деле бессмысленно, потому как лучше применить описания решения проблем в моменты их возникновения.

В чем различие между шириной и шагом…

IDirect3DSurface9

"Surface" в переводе с английского — "поверхность", и в данном случае мы будем применять этот термин относительно двухмерных изображений, которые либо отображаются на экране, либо формируются и хранятся в памяти. По существу, они являются не чем иным, как массивами пикселей. В коде поверхность представляется интерфейсом IDirect3DSurface9. Через него мы получаем доступ к внутреннему хранилищу данных, можем их менять, производить чтение и запись, получать любую необходимую информацию. На самом деле, многим кажется, что саму поверхность проще всего представлять в виде двумерного массива (или матрицы) пикселей, в результате чего будут явно прослеживаться данные о координатном месторасположении каждой точки (пикселя), а всякая из них несет в себе не что иное, как информацию о цвете. В ряде случаев это было бы уместно, но внутренняя структура хранения двухмерных изображений в нашем случае иная. Массив пикселей является линейным, то есть каждый пиксель имеет одномерный адрес, а как это выгоднее представить в виде упорядоченной структуры? На самом деле поверхность описывается более емкими элементами-строками, иначе говоря, шагами (pitch), которые считаются в байтах. Это намного проще и удобнее в расчетах, а также ближе соответствует самой структуре хранения информации. Шаг — это расстояние (в байтах) между двумя адресами памяти, первый из которых указывает на начало одной строки изображения, а второй — на начало следующей. В простейшем виде вы могли бы предположить, что строка соответствует формуле:
Шаг = Фактическая ширина изображения (в пикселях), умноженная на Количество байт в пикселе.

Так бы оно и было, если бы не специфика используемого оборудования (раз) и вариантов, когда поверхность имеет одни и те же размеры, но разные форматы пикселей (два), а также некоторых других тонких моментов. Для этих целей в рамках Direct3D отдельно зарезервирована часть памяти, а именно кэш. Например, для изображения 640х480х8 выделяются основные первичные и вторичные буферы памяти для хранения 640х480х8 и дополнительно к каждому из них предусмотрен кэш 384x480x8. Нужно ли вам так учитывать то, что происходит "внутри кухни"? На самом деле нет — просто нужно использовать тот шаг, который возвращает метод IDirect3DSurface9::LockRect. Если вы хотите обращаться к поверхности напрямую, то используйте память, выделенную для хранения основной структуры поверхности, и держитесь подальше от того, что зарезервировано для кэша, то есть активно внедряться во внутреннюю структуру хранения не требуется. Теперь подробно пройдемся по основным методам IDirect3DSurface9. Для того, чтобы получить доступ к внутренним данным поверхности (или ее части), нам нужно сначала ее (или ее часть) заблокировать:

. LockRect. Блокировка поверхности, получение указателя на ее память. Используя стандартные арифметические операции, которые мы рассмотрим на примере чуть позже, можно получить доступ к каждому пикселю, считывать и записывать данные. Речь идет о работе со структурой
D3DSURFACE_RECT.
. UnlockRect. Разблокировка поверхности после вызова метода LockRect.
. GetDesc. С помощью данного метода возвращаются параметры поверхности и заполняется структура D3DSURFACE_DESC.

Для начала рассмотрим определения структур D3DSURFACE_DESC и D3DSURFACE_RECT, которые вы можете легко найти в той же документации к Direct3D: typedef struct D3DSURFACE_DESC {
D3DFORMAT Format;
D3DRESOURCETYPE Type;
DWORD Usage;
D3DPOOL Pool;
D3DMULTISAMPLE_TYPE MultiSampleType;
DWORD MultiSampleQuality;
UINT Width;
UINT Height;
} D3DSURFACE_DESC;

В данном случае Format — член перечисляемого типа D3DFORMAT, описывающий поверхность, а если проще, то тут как раз и описывается формат представления пикселей. Если вы захотите изучить данный вопрос более конкретно, рекомендуется обратиться к документации, где перечислены все типы форматов. Дело в том, что в рамках D3DFORMAT описывается очень много различных стандартов, включающих форматы для дисплеев, буферов памяти, сжатых текстур (DXTn), IEEE, Floating-Point, MAKEFOURCC и т.д. Именно поэтому мы ранее говорили о том, что поверхность может при одних и тех же размерах иметь различные форматы пикселей. Наиболее часто используемые:

. D3DFMT_R8G8B8 (24 бита, 8 бит — красный, 8 бит — зеленый, 8 бит — синий) — обратите внимание, что мы говорим не RGB, а указываем более конкретно R8G8B8 — эти аббревиатуры очень легко читаются;
. D3DFMT_X8R8G8B8 (32 бита, 8 бит — не занято, 8 бит — красный, 8 бит — зеленый, 8 бит — синий);
. D3DFMT_A8R8G8B8 (32 бита, 8 бит — альфа-канал, 8 бит — красный, 8 бит — зеленый, 8 бит — синий);
. D3DFMT_A16B16G16R16F (64 бита, 16 бит — альфа-канал, 16 бит — красный, 16 бит — зеленый, 16 бит — синий);
. D3DFMT_A32B32G32R32F (128 бит, 32 бита — альфа-канал, 32 бита — красный, 32 бита — зеленый, 32 бита — синий);

При этом есть ряд специфичных, например, D3DFMT_X1R5G5B5 или D3DFMT_R3G3B2. Все используемые варианты можно найти в документации в разделе Unsignet Formats. Смотрим далее на определение структуры D3DSURFACE_DESC… Type — член перечисляемого типа D3DRESOURCETYPE, идентифицирующий данный ресурс как поверхность. Usage — идентификация вариантов использования ресурса, Pool — вариант выделения памяти под поверхность, MultiSampleType и MultiSampleQuality — характеристики множественной выборки (multisamling), о которой мы подробнее расскажем ниже, Wight и Height — ширина и высота поверхности в пикселях. Теперь перейдем к определению структуры D3DSURFACE_RECT, которая гораздо проще:

typedef struct D3DLOCKED_RECT {
INT Pitch;
void *pBits;
} D3DLOCKED_RECT;

Pitch — это непосредственно шаг поверхности, а *pBits — указатель на начало памяти поверхности. Теперь перейдем непосредственно к практике использования. Допустим, у нас стоит задача закрасить поверхность в зеленый цвет — она представляется 32-битными пикселями в формате ARGB (зеленый обозначим как 0xff00ff00 — четыре восьмиразрядные секции, каждая из которых отвечает за интенсивность одного из компонентов: альфа- канал, красный, зеленый, синий). Представим, что мы уже создали указатель на IDirect3DSurface9, назвав его _surf_1. Для начала нам нужно получить описание самой поверхности:

D3DSURFACE_DESC surf_1Desc;
_surf1->GetDesc(&surf_1Desc);

Теперь нам нужно получить указатель на данные пикселей поверхности &lockedArea, при этом мы блокируем всю поверхность (указываем 0) и без дополнительных параметров блокировки (тоже пишем 0). Код будет выглядеть так:

D3DLOCKED_RECT lockedArea;
_surf1->LockRect(
&lockedArea,
0,
0);

Теперь мы сделаем непосредственно попиксельную перекраску в зеленый:

DWORD* imageData = (DWORD*) lockedArea.pBits;
//начинаем перебор!
for(int i = 0; i < surf_1Desc.Height; i++) {
//вертикаль
for(int j = 0; j < surf_1Desc.Width; j++) {
//горизонталь
int pix_index = i * lockedArea.Pitch / 4 + j;
imageData[pix_index] = 0xff00ff00; }
}

…и делаем разблокировку:

_surf1->UnlockRect();

Все. Теперь обратите внимание на строку из набранного кода, а именно где описывается переменная, отвечающая за индекс пикселя, а именно: int pix_index = i * lockedArea.Pitch / 4 + j;

В данном случае lockedArea.Pitch — это шаг, возвращенный из IDirect3DSurface9::LockRect. Делим мы его на 4, потому как в каждом пикселе хранится 4 байта (32 бита), а нам нужен полноценный порядковый номер. Еще раз обратите внимание на то, что пиксели представлены в виде одномерного массива. Так, для самой первой строки, которая, по существу, вернее, по законам программирования, считается нулевой, отсчет пикселей будет производиться как:
int pix_index = 0 * lockedArea.Pitch / 4 + j;

другими словами: int pix_index = j; при полном переборе всех пикселей строки, количество которых нам известно из полученных данных из структуры D3DSURFACE_DESC. Вторая строка начинает вычисляться при i=1, и здесь нам уже необходимо делить количество байт шага на 4. Хотя на самом деле иногда лучше проверять формат поверхности и в соответствии с ним ставить делитель.

Множественная выборка. Большинство алгоритмов сглаживания (antialiasing) предусматривают создание градиентных переходов. Их расчет занимает много аппаратных ресурсов.

Множественная выборка

Множественная выборка, о которой мы вскользь упомянули выше — вернее, ее всевозможные типы, — предусмотрены в перечислении D3DMULTISAMPLE_TYPE. В данном случае речь идет о сглаживании изображения. Тот, кто рассматривал изблизи тривиальную пиксельную графику, может сказать о ее угловатости и неровности. Это возникает в силу того, что изображение формируется из точек, и, к тому же, очень часто можно наблюдать резкие переходы от одного цвета к другому, что плохо сказывается на визуальном восприятии. Например, как уже было не раз озвучено, текст с экрана монитора в ряде случаев воспринимается на 30% труднее, нежели с бумаги. Кстати, такие исследования проводились уже давно, а сейчас алгоритмы сглаживания предусмотрены везде, даже в рамках драйверов для ЖКИ-мониторов (с ними поставляется ПО типа TrueType). Все эти технологии объединены под общим термином antialiasing. Алгоритмов множество, но практически все они предусматривают создание плавных градиентных переходов на границах между графическими объектами. Что интересно, в такой среде разработки, как Adobe Flash, тип антиалиасинга указывается отдельно даже для используемого монитора, причем там анализируется не только разность цветов на границах, но и, например, общая цветовая гамма изображения и т.д. В принципе, когда мы говорим о нашем случае, то есть о Direct3D, то, как многие помнят из прошлых выпусков серии, сам он в ряде случаев ничего не делает, а только направляет — вернее, руководит — процессом через HAL. Сами же алгоритмы сглаживания должны быть предусмотрены производителями видеоадаптеров. Чтобы проверить наличие таковой поддержки аппаратной частью, нужно использовать метод IDrect3D9::CheckDeviceMultiSampleType. Само же перечисление D3DMULTISAMPLE_TYPE содержит обычные константы от 0 до 16, которые отображают сам уровень множественной выборки. Например, D3DMULTISAMPLE_NONE говорит о ее отсутствии, а диапазон значений D3DMULTISAMPLE_NONMASKABLE (это первый уровень), D3DMULTISAMPLE_2_SAMPLE… D3DMULTISAMPLE_16_SAMPLES — о вариантах выбора. Причем все они описывают варианты алгоритмов full-scene, то есть полноэкранных. Качество множественной выборки во многом зависит от возможностей видеоадаптера.

В принципе, если вы представите себе саму реализацию алгоритма сглаживания, то поймете, что она не самая простая для ресурсов аппаратуры. В некоторых случаях оная замедляет работу приложений в разы. Например, в том же Adobe Flash, о котором мы недавно вспомнили, antialiasing предусмотрен как программный алгоритм, являющийся свойством некоторых типов графических объектов, он дан оптимизированно и практически закрыто. То есть, если вы начнете его копать и разбирать буквально, то можете запросто сделать приложение из простого для системы в очень тяжелое. Кстати, это же относится и к реализации градиента как такового. В принципе, очень часто на множественную выборку разработчики смотрят сквозь пальцы, могут ее и вовсе не использовать. И нужно сказать, что "такая лень" во многом оправдана, потому как расчетов производится огромное количество.

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

Что такое "конвейер визуализации"?

Сейчас мы только начинаем вводить понятие конвейера визуализации. Как уже говорилось выше, при выводе на экран мы оперируем поверхностями, то есть двухмерными изображениями, например, для каждого кадра. Визуализация — а в области трехмерной графики чаще оперируют синонимичным понятием "рендеринг" — это сложный каскад операций, преобразующих смоделированную трехмерную реальность в вид 2D-картинки. Поэтому достаточно часто, говоря "визуализация", мы будем подразумевать не что иное, как формирование окончательной картинки, выводимой на экран.

IDirect3DSwapChain9. Ключевые буферы памяти

Почему в подразделе IDirect3DSurface9 мы не стали говорить о том, что поверхность — это 2D-изображение, выводимое на экран? На самом деле, система памяти распределена на два существенных производственных этапа, а именно вывод кадра на экран (1), и параллельно с этим идет подготовка или расчеты следующего кадра (2). То есть сама визуализация в конкретном смысле происходит при расчетах, а на экране мы видим лишь их результаты, в то время как конвейер занят подготовкой того, что еще должно появиться следующим. Достигается все это с помощью использования двух ключевых буферов памяти: первичного (front buffer) и вторичного (back buffer). Первичный отвечает за вывод поверхности на экран, во вторичный же помещаются результаты визуализации, в рамках которой рассчитывается следующий кадр. После отображения поверхности на мониторе в первичный загружаются данные из вторичного, и он опять принимается за свое привычное дело. SwapChain переводится как цепочка обмена. Причем все происходит практически на автомате, то есть, опять же, внедряться внутрь "кухни" Direct3D не нужно — достаточно иметь общее представление о том, как все работает.

На самом деле это очень удобно для синхронизации происходящего, причем именно такой метод организации процесса используется не только в графике, но и вообще во многих приложениях, связанных с real-time-режимом, интерактивностью и т.п., и даже не только в рамках DirectX. Весьма интересно, как иногда авторы различных книг описывают плюсы цепочки переключений с двумя буферами, но… по существу, по-другому никак разумно и не сделаешь. Просто не сделаешь. Как говорится в известной фразе: "Мухи — отдельно, котлеты — отдельно". Визуализация может происходить гораздо быстрее процесса вывода изображения на экран, причем ее скорость напрямую зависит от сложности формируемого.

Почему мы сказали "ключевые буферы"? Дело в том, что этим понятием описывается общая структура и порядок действия, а при самих расчетах используется большое количество буферов различного предназначения. Один из примеров — Z-буферизация, о которой мы поговорим в следующем материале…

Промежуточное завершение

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

Кристофер christopher@tut.by


Компьютерная газета. Статья была опубликована в номере 37 за 2008 год в рубрике технологии

©1997-2024 Компьютерная газета