Сетка с треугольными ячейками
Рисунок 13.1. (a) Сетка с треугольными ячейками. (б) Сетка с плавным изменением высот. (в) Освещенный и текстурированный ландшафт, который будет формировать пример приложния, создаваемый в данной главе
Эта глава познакомит вас с реализацией класса Terrain, использующим метод грубой силы. Под этим подразумевается, что он просто хранит данные вершин и индексов для всего ландшафта и визуализирует их. Для игр с ландшафтом небольшого размера такой подход работает, при том условии, что в компьютере установлена современная видеокарта поддерживающая аппаратную обработку вершин. Однако для тех игр, которым требуется большой ландшафт, мы должны выполнить дополнительную работу по изменению уровня детализации или отбрасыванию невидимых граней, поскольку при использовании метода грубой силы огромное количество геометрических данных, необходимых для моделирования обширных ландшафтов приведет к перегрузке видеокарты.
Цели | |
Узнать как генерируется информация о высотах ландшафта, обеспечивающая плавный переход от гор к долинам, подобный природным ландшафтам. Изучить способы генерации данных о вершинах и треугольных гранях ландшафта. Познакомиться с техникой, применяемой для освещения и текстурирования ландшафта. Изучить способ перемещения камеры по ландшафту, чтобы создавалось впечатление ходьбы или бега. |
Вычисляем два вектора, совпадающих
Рисунок 13.12. (а) Вычисляем два вектора, совпадающих со сторонами треугольника. (б) Высота вычисляется путем линейной интерполяции u на dx и v на dz
Обратите внимание, что поскольку нас интересует только значение высоты, мы можем выполнять интерполяцию только для компоненты y и игнорировать остальные компоненты. В этом случае высота определяется по формуле A + dxuy + dzvy.
Итак, вот заключительная часть кода метода Terrian::getHeight:
if(dz < 1.0f - dx) // верхний треугольник ABC { float uy = B - A; // A->B float vy = C - A; // A->C
height = A + d3d::Lerp(0.0f, uy, dx) + d3d::Lerp(0.0f, vy, dz); } else // нижний треугольник DCB { float uy = C - D; // D->C float vy = B - D; // D->B
height = D + d3d::Lerp(0.0f, uy, 1.0f - dx) + d3d::Lerp(0.0f, vy, 1.0f - dz); } return height; }
Функция Lerp выполняет линейную интерполяцию вдоль одномерной линии и ее реализация выглядит так:
float d3d::Lerp(float a, float b, float t) { return a - (a*t) + (b*t); }
Досуп к карте высот и ее модификация
13.1.3. Досуп к карте высот и ее модификация
Для доступа к элементам карты высот и их модификации класс Terrain предоставляет следующие два метода:
int Terrain::getHeightmapEntry(int row, int col) { return _heightmap[row * _numVertsPerRow + col]; }
void Terrain::setHeightmapEntry(int row, int col, int value) { _heightmap[row * _numVertsPerRow + col] = value; }
Эти методы позволяют ссылаться на элемент карты, указывая номера строки и столбца, и скрывают выполняемое преобразование в индекс одномерного массива.
Основы визуализации ландшафтов
Сетка ландшафта представляет собой обычную сетку с треугольными ячейками, подобную показанной на Рисунок 13.1(а), у которой для каждой вершины сетки высота задана таким образом, чтобы сетка моделировала плавный переход от гор к долинам, подобный природному ландшафту (Рисунок 13.1(б)). И, конечно же, мы накладываем тщательно подобранные текстуры, изображающие песчанные пляжи, покрытые травой склоны и заснеженные вершины (Рисунок 13.1(в)).
«Ходьба» по ландшафту
После того, как мы сконструировали ландшафт, хорошо добавить возможность перемещать камеру таким образом, чтобы она имитировала ходьбу по ландшафту. То есть нам надо менять высоту камеры (координату Y) в зависимости от того, в каком месте ландшафта мы находимся. Для этого мы сначала должны определить по координатам X и Z камеры квадрат ландшафта в котором мы находимся. Все это делает функция Terrain::getHeight; в своих параметрах она получает координаты X и Z камеры и возвращает высоту, на которой должна быть расположена камера, чтобы она оказалась над ландшафтом. Давайте рассмотрим реализацию функции.
float Terrain::getHeight(float x, float z) { // Выполняем преобразование перемещения для плоскости XZ, // чтобы точка START ландшафта совпала с началом координат. x = ((float)_width / 2.0f) + x; z = ((float)_depth / 2.0f) - z;
// Масштабируем сетку таким образом, чтобы размер // каждой ее ячейки стал равен 1. Для этого используем // коэффициент 1 / cellspacing поскольку // cellspacing * 1 / cellspacing = 1. x /= (float)_cellSpacing; z /= (float)_cellSpacing;
Сперва мы выполняем перемещение в результате которого начальная точка ландшафта будет совпадать с началом координат. Затем мы выполняем операцию масштабирования с коэффициентом равным единице деленной на размер клетки; в результате размер клетки ландшафта будет равен 1. Затем мы переходим к новой системе координат, где положительное направление оси Z направлено «вниз». Конечно, вы не найдете кода меняющего систему координат, просто помните, что ось Z направлена вниз. Все эти этапы показаны на Рисунок 13.9.
Исходная сетка ландшафта и сетка
Рисунок 13.9. Исходная сетка ландшафта и сетка после переноса начальной точки ландшафта в начало координат, масштабирования, делающего размер квадрата равным 1 и смены направления оси Z
Мы видим, что измененная координатная система соответствует порядку элементов матрицы. То есть верхний левый угол — это начало координат, номера столбцов увеличиваются вправо, а номера строк растут вниз. Следовательно, согласно рис 13.9, и помня о том, что размер ячейки равен 1, мы сразу видим что номер строки и столбца для той клетки на которой мы находимся вычисляется так:
float col = ::floorf(x); float row = ::floorf(z);
Другими словами, номер столбца равен целой части координаты X, а номер строки — целой части координаты Z. Также вспомните, что результатом функции floor(t) является наибольшее целое число, которое меньше или равно t.
Теперь, когда мы знаем в какой ячейке находимся, можно получить высоты ее четырех вершин:
// A B // *---* // | / | // *---* // C D float A = getHeightmapEntry(row, col); float B = getHeightmapEntry(row, col+1); float C = getHeightmapEntry(row+1, col); float D = getHeightmapEntry(row+1, col+1);
Теперь мы знаем в какой ячейке находимся и высоты всех четырех ее вершин. Нам надо найти высоту (координату Y) точки ячейки с указанными координатами X и Z, где находится камера. Это нетривиальная задача, поскольку ячейка может быть наклонена, как показано на Рисунок 13.10.
Мы можем моделировать ландшафты используяМы можем моделировать ландшафты используя сетку с треугольными ячейками, вершинам которой присвоены различные высоты для создания возвышенностей и низин, имитирующих ландшафт. Карта высот — это набор данных, содержащий значения высот для каждой вершины ландшафта. Мы можем текстурировать ландшафт, используя в качестве текстуры хранящееся на диске изображение, либо создавая текстуру программно. Мы освещаем ландшафт путем вычисления коэффициента затенения для каждого квадрата, определяющего насколько светлым или темным должен быть квадрат. Коэффициент затенения зависит от угла под которым свет падает на квадрат. Чтобы камера имитировала «ходьбу» по ландшафту нам нужно уметь определять на каком треугольнике сетки ландшафта мы находимся в данный момент. После этого мы формируем два вектора, совпадающих со сторонами этого треугольника. После этого высота определяется путем линейной интерполяции этих векторов с использованием координат по осям X и Z в нормализованной ячейке ландшафта, начало которой совпадает с началом координат, а размеры сторон равны единице. Ячейка до и после преобразования, переносящего ее верхнюю левую вершину в начало координат
Теперь, если dz < 1.0 – dx мы находимся в «верхнем» треугольнике Δv0v1v2. В ином случае мы находимся в «нижнем» треугольнике Δv0v2v3 (Рисунок 13.10). Теперь мы посмотрим как определить высоту, если мы находимся в «верхнем» треугольнике. Для «нижнего» треугольника процесс аналогичен и ниже будет приведен код для обоих вариантов. Чтобы найти высоту когда мы находимся в «верхнем» треугольнике, надо сформировать два вектора u = (cellSpacing, B – A, 0) и v = (0, C – A, –cellSpacing), совпадающих со сторонами треугольника и начинающихся в точке, заданной вектором q = (qx, A, qz), как показано на Рисунок 13.12(а) Затем мы выполняем линейную интерполяцию вдоль вектора u на dx и вдоль вектора v на dz. Эти интерполяции показаны на Рисунок 13.12(б). Координата Y вектора (q + dxu + dzv) дает нам высоту для заданных координат X и Z; чтобы увидеть, как это происходит, вспомните геометрическую интерпретацию сложения векторов. Карта градаций серого, созднная в Adobe Photoshop
Когда вы завершите рисование своей карты высот, сохраните ее как 8-разрядный файл RAW. Файлы RAW просто содержат байты изображения один за другим. Благодаря этому чтение данных изображения в приложении выполняется очень просто. Ваш графический редактор может спростить, сохранять ли изображение в файл RAW с заголовком. Укажите, что заголовок не нужен. ПРИМЕЧАНИЕ | Вы не обязаны использовать для хранения информации о высотах формат RAW; применяйте любой формат, соответствующий вашим потребностям. Формат RAW — это только один пример формата, который можно использовать. Мы выбрали его по той причине, что большинство популярных графических редакторов могут выполнять экспорт изображения в этот формат и чтение данных из файла RAW в приложении реализуется очень просто. В примерах из этой главы используются 8-разрядные файлы RAW. |
Обзор
Техника освещения, которую мы будем использовать для вычисления оттенков ландшафта является одной из самых простых и известна как рассеянное освещение (diffuse lighting). Мы используем параллельный источник света, который описываем путем указания направления на источник света, являющегося противоположным тому направлению, в котором падают испускаемые источником лучи. Например, если мы хотим чтобы лучи света падали с неба вертикально вниз в направлении lightRaysDirection= (0, –1, 0), то направлением на источник света будет указывающий в противоположном направлении вектор directionToLight = (0, 1, 0). Обратите внимание, что мы используем единичные векторы. ПРИМЕЧАНИЕ | Хотя указание направления испускаемых источником света лучей может казаться более интуитивно понятным, задание направления на источник света больше подходит для вычисления рассеянного освещения. |
И, в заключение, приведем код генерации данных вершин:
bool Terrain::computeVertices() { HRESULT hr = 0;
hr = _device->CreateVertexBuffer( _numVertices * sizeof(TerrainVertex), D3DUSAGE_WRITEONLY, TerrainVertex::FVF, D3DPOOL_MANAGED, &_vb, 0);
if(FAILED(hr)) return false;
// координаты, с которых начинается генерация вершин int startX = -_width / 2; int startZ = _depth / 2;
// координаты, на которых завершается генерация вершин int endX = _width / 2; int endZ = -_depth / 2;
// вычисляем приращение координат текстуры // при переходе от одной вершины к другой. float uCoordIncrementSize = 1.0f / (float)_numCellsPerRow; float vCoordIncrementSize = 1.0f / (float)_numCellsPerCol;
TerrainVertex* v = 0; _vb->Lock(0, 0, (void**)&v, 0);
int i = 0; for(int z = startZ; z >= endZ; z -= _cellSpacing) { int j = 0; for(int x = startX; x <= endX; x += _cellSpacing) { // вычисляем правильный индекс в буфере вершин // и карте высот на основании счетчиков вложенных циклов int index = i * _numVertsPerRow + j;
v[index] = TerrainVertex( (float)x, (float)_heightmap[index], (float)z, (float)j * uCoordIncrementSize, (float)i * vCoordIncrementSize);
j++; // следующий столбец } i++; // следующая строка }
_vb->Unlock();
return true; }
Создание карты высот
13.1.1. Создание карты высот
Карта высот может быть создана либо программно, либо с помощью графического редактора, например Adobe Photoshop. Использование графического редактора— более простой способ, позволяющий интерактивно создавать ландшафты и видеть полученный результат. Кроме того, можно создавать необычные карты высот, пользуясь дополнительными возможностями графического редактора, такими как фильтры. На Рисунок 13.3 показана пирамидальная карта высот, созданная в Adobe Photoshop с помощью инструментов редактирования. Обратите внимание, что при создании изображения мы выбираем карту градаций серого.
Свойства размеченной треугольной сетки Точки на пересечении линий сетки обозначают вершины
Рисунок 13.4. Свойства размеченной треугольной сетки. Точки на пересечении линий сетки обозначают вершины
Рисунок 13.4 иллюстрирует свойства ландшафта, термины и специальные точки, на которые мы будем ссылаться. Размер ландшафта определяется путем указания количества вершин в строке, количества вершин в столбце и размера ячейки. Все эти значения мы передаем в класс Terrain. Кроме того, мы передаем указатель на связанное с ландшафтом устройство, строку с именем файла, содержащего карту высот и коэффициент масштабирования, используемый при масштабировании элементов карты высот.
class Terrain { public: Terrain( IDirect3DDevice9* device, std::string heightmapFileName, int numVertsPerRow, int numVertsPerCol, int cellSpacing, // расстояние между вершинами float heightScale); // коэффициент масштабирования высоты
... методы пропущены
private: ...устройство, буфер вершин и т.п. пропущены
int _numVertsPerRow; int _numVertsPerCol; int _cellSpacing; int _numCellsPerRow; int _numCellsPerCol; int _width; int _depth; int _numVertices; int _numTriangles; float _heightScale; };
Чтобы увидеть объявление класса Terrain полностью, посмотрите исходный код примера в сопроводительных файлах; оно слишком велико, чтобы приводить его в тексте.
На основании переданных конструктору данных мы вычисляем другие параметры ландшафта:
_numCellsPerRow = _numVertsPerRow - 1; _numCellsPerCol = _numVertsPerCol - 1; _width = _numCellsPerRow * _cellSpacing; _depth = _numCellsPerCol * _cellSpacing; _numVertices = _numVertsPerRow * _numVertsPerCol; _numTriangles = _numCellsPerRow * _numCellsPerCol * 2;
Кроме того, мы объявляем структуру вершин ландшафта следующим образом:
struct TerrainVertex { TerrainVertex(){} TerrainVertex(float x, float y, float z, float u, float v) { _x = x; _y = y; _z = z; _u = u; _v = v; } float _x, _y, _z; float _u, _v;
static const DWORD FVF; }; const DWORD Terrain::TerrainVertex::FVF = D3DFVF_XYZ | D3DFVF_TEX1;
Обратите внимание, что TerrainVertex — это вложенный класс, объявленный внутри класса Terrain. Мы выбрали такую реализацию по той причине, что класс TerrainVertex не нужен вне класса Terrain.
Текстурирование
Класс Terrain предоставляет два способа текстурирования ландшафта. Наиболее очевидный способ— загрузить ранее подготовленную текстуру из файла и использовать ее. Показанный ниже метод, реализованный в классе Terrain, загружает текстуру из файла в член данных _tex, являющийся указателем на интерфейс IDirect3DTexture9. Внутри метода Terrain::draw перед визуализацией ландшафта устанавливается текстура _tex.
Если вы прочитали предыдущие главы, реализация метода не должна вызвать у вас никаких вопросов.
bool Terrain::loadTexture(std::string fileName) { HRESULT hr = 0;
hr = D3DXCreateTextureFromFile( _device, fileName.c_str(), &_tex);
if(FAILED(hr)) return false;
return true; }
Угол между вектором освещения
Рисунок 13.7. Угол между вектором освещения
Используя угловые отношения между вектором освещения и нормалю поверхности можно вычислить коэффициент затенения, находящийся в диапазоне [0, 1], который определяет сколько света получает поверхность. Большие углы представляются близкими к нулю значениями коэффициента. Когда цвет умножается на близкий к нулю коэффициент затенения, он становится темнее, а это именно то, что нам надо. С другой стороны, малые углы представляются близкими к единице значениями коэффициента, и умножение на этот коэффициент практически не меняет яркость цвета.
Вершины квадрата
Рисунок 13.6. Вершины квадрата
<
Главным трюком здесь является общая формула, позволяющая вычислить индексы треугольников, образующих квадрат в позиции (i, j). Рисунок 13.6 позволяет вывести общую формулу для квадрата(i, j):
ΔABC = { i*numVertsPerRow + j, i*numVertsPerRow + j + 1, (i + 1)*numVertsPerRow + j }
ΔCBD = { (i + 1)*numVertsPerRow + j, i*numVertsPerRow + j + 1, (i + 1)*numVertsPerRow + j + 1 }
Вот код генерации индексов:
bool Terrain::computeIndices() { HRESULT hr = 0;
hr = _device->CreateIndexBuffer( _numTriangles * 3 * sizeof(WORD), // 3 индекса на треугольник D3DUSAGE_WRITEONLY, D3DFMT_INDEX16, D3DPOOL_MANAGED, &_ib, 0);
if(FAILED(hr)) return false;
WORD* indices = 0; _ib->Lock(0, 0, (void**)&indices, 0);
// Индекс, с которого начинается группа из 6 индексов, // описывающая два треугольника, образующих квадрат int baseIndex = 0;
// В цикле вычисляем треугольники для каждого квадрата for(int i = 0; i < _numCellsPerCol; i++) { for(int j = 0; j < _numCellsPerRow; j++) { indices[baseIndex] = i * _numVertsPerRow + j; indices[baseIndex + 1] = i * _numVertsPerRow + j + 1; indices[baseIndex + 2] = (i+1) * _numVertsPerRow + j; indices[baseIndex + 3] = (i+1) * _numVertsPerRow + j; indices[baseIndex + 4] = i * _numVertsPerRow + j + 1; indices[baseIndex + 5] = (i+1) * _numVertsPerRow + j + 1;
// следующий квадрат baseIndex += 6; } }
_ib->Unlock();
return true; }
Возможные усовершенствования
Рассмотренная реализация класса Terrain загружает данные всех вершин ландшафта в один огромный буфер вершин. С точки зрения скорости и масштабируемости было бы хорошо разделить геометрию ландшафта на несколько буферов вершин. Возникает вопрос: «Какой размер буфера лучше выбрать?». Ответ зависит от конфигурации аппаратуры компьютера. Так что экспериментируйте!
Поскольку деление геометрии ландшафта на несколько небольших буферов вершин в основном связано с индексацией в подобной матрице структуре данных и управленим данными, а также не вносит никаких новых концепций, мы не станем подробно обсуждать его. Если коротко, вы делите ландшафт на матрицу элементов, которые мы будем называть «блоки». Каждый блок охватывает прямоугольный фрагмент ландшафта и содержит описание геометрии этого фрагмента (в собственных буферах вершин и индексов). Следовательно, каждый блок отвечает за рисование той части ландшафта, данные которой он содержит.
Альтернативным способом является загрузка всей геометрии ландшафта в один большой экземпляр интерфейса ID3DXMesh. Затем для разделения сетки ландшафта на несколько меньших используется функция D3DXSplitMesh из библиотеки D3DX. Прототип функции D3DXSplitMesh выглядит так:
void D3DXSplitMesh( const LPD3DXMESH pMeshIn, const DWORD *pAdjacencyIn, const DWORD MaxSize, const DWORD Options, DWORD *pMeshesOut, LPD3DXBUFFER *ppMeshArrayOut, LPD3DXBUFFER *ppAdjacencyArrayOut, LPD3DXBUFFER *ppFaceRemapArrayOut, LPD3DXBUFFER *ppVertRemapArrayOut );
Эта функция получает исходную сетку и делит ее на несколько меньших. Параметр pMeshIn — это указатель на сетку, которую мы хотим разделить, а pAdjacencyIn — это указатель на массив данных о смежности для этой сетки. Параметр MaxSize используется для указания максимального количества вершин в получаемых в результате разделения фрагментах сетки. Набор флагов Options задает параметры создания для формируемых сеток-фрагментов. Через параметр pMeshesOut возвращается количество полученных фрагментов, а сами фрагменты возвращаются в массиве буферов ppMeshArrayOut. Последние три параметра — необязательные (чтобы игнорировать их укажите null) и возвращают массивы данных о смежности и информацию о перемещении граней и вершин для каждой из созданных сеток.
Вычисление двух векторов, находящихся в одной плоскости с квадратом
Рисунок 13.8. Вычисление двух векторов, находящихся в одной плоскости с квадратом
Вычисление индексов— определение треугольников
13.2.2. Вычисление индексов — определение треугольников
Чтобы вычислить индексы сетки мы просто в цикле перебираем все ее квадраты, начиная с верхнего левого и заканчивая правым нижним, как показано на Рисунок 13.4, и формируем два треугольника, образующие квадрат.
Вычисление вершин
Во время последующего обсуждения смотрите на Рисунок 13.4. Чтобы создать вершины нашей сетки мы просто начинаем генерировать данные вершин с той, которая отмечена на рисунке словом start и заполняем вершины ряд за рядом, пока не дойдем до той, которая отмечена словом end; при этом расстояние между вершинами задается параметром cellSpacing. Это позволяет нам получить координаты X и Z, но как насчет координаты Y? Координата Y берется из соответствующего элемента загруженной карты высот. ПРИМЕЧАНИЕ | Рассматриваемая реализация использует один большой буфер вершин для хранения данных всех вершин всего ландшафта. Это может вызвать проблемы, связанные с накладываемым оборудованием ограничениями. Например, параметры устройства задают максимальное количество примитивов и максимальное количество индексов вершин. Проверьте значения членов MaxPrimitiveCount и MaxVertexIndex структуры D3DCAPS9, чтобы увидеть какие ограничения накладывает используемое вами оборудование. Решение проблем ,вызванных использованием одного буфера вершин, обсуждается в разделе 13.7. |
Чтобы определить высоту мы должны узнать в каком треугольнике ячейки мы находимся. Вспомните, что каждая ячейка визуализируется как два треугольника. Чтобы определить в каком треугольнике мы находимся, мы берем тот квадрат сетки в котором находимся и перемещаем его таким образом, чтобы верхняя левая вершина совпадала с началом координат.
Поскольку переменные row и col определяют местоположение левой верхней вершины той ячейки где мы находимся, необходимо выполнить перемещение на –col по оси X и –row по оси Z. Преобразование координат X и Z выполняется так:
float dx = x - col; float dz = z - row;
Ячейка после выполнения преобразования показана на Рисунок 13.11.
Загрузка файла RAW
13.1.2. Загрузка файла RAW
Поскольку файл RAW— это всего лишь непрерывный массив байтов, мы можем просто прочитать его целиком с помощью приведенного ниже метода. Обратите внимание, что переменная _heightmap — это член класса Terrain, определеный следующим образом: std::vector<int> _heightmap;.
bool Terrain::readRawFile(std::string fileName) { // Высота для каждой вершины std::vector<BYTE> in(_numVertices);
std::ifstream inFile(fileName.c_str(), std::ios_base::binary);
if(inFile == 0) return false;
inFile.read( (char*)&in[0], // буффер in.size()); // количество читаемых в буфер байт
inFile.close();
// копируем вектор BYTE в вектор int _heightmap.resize(_numVertices); for(int i = 0; i < in.size(); i++) _heightmap[i] = in[i];
return true; }
Обратите внимание, что мы копируем вектор байтов в вектор целых чисел; это делается для того, чтобы потом мы могли масштабировать значения высот для выхода за пределы диапазона [0, 255].
Единственным ограничением данного метода является то, что количество байт в читаемом файле RAW должно быть не меньше количества вершин в сетке ландшафта. Следовательно, если вы считываете файл RAW размером 256 × 256, то должны создать ландшафт в котором будет не более 256 × 256 вершин.
Затенение ландшафта
13.4.3. Затенение ландшафта
Теперь, когда мы узнали как выполнить затенение отдельного квадрата, можно выполнить затенение всех квадратов ландшафта. Мы просто перебираем в цикле все квадраты ландшафта, вычисляем для каждого из них коэффициент затенения и умножаем цвет соответствующего квадрату текселя на полученный коэффициент. В результате квадраты, получающие мало света станут темнее. Ниже приведен фрагмент кода, являющийся главной частью метода Terrain::lightTerrain:
DWORD* imageData = (DWORD*)lockedRect.pBits; for(int i = 0; i < textureDesc.Height; i++) { for(int j = 0; j < textureDesc.Width; j++) { int index = i * lockedRect.Pitch / 4 + j;
// Получаем текущий цвет ячейки D3DXCOLOR c(imageData[index]);
// Затеняем текущую ячейку c *= computeShade(i, j, lightDirection);;
// Сохраняем затененный цвет imageData[index] = (D3DCOLOR)c; } }