Привет! К сожалению, очень давно не писал на хабр. Личные дела совершенно были против того, чтобы сесть и написать пару статей по геймдеву. Может оно и к лучшему, за эти два года я набрался очень много опыта и всегда рад им поделиться. Стоит отметить, что я совершенно отказался от создания 2D игр: я не против их, но разрабатывать игры в 3D куда интереснее и веселее! По традиции — в качестве инструмента будет XNA 4.0, почему XNA 4.0 дорогой слушатель? А все потому, что, он до сих пор остается актуальной для инди-разработчиков. Даже несмотря на то, что технология была успешно похоронена майкрософтом. У нас есть язык с очень низким вхождением — C#. Есть тот самый фреймворк XNA с необходимыми начальными классами/структурами и алгоритмами. И есть DirectX с поддержкой шейдеров, вплоть до Shader Model 3.0. Если ты, %username%, читаешь меня впервые, то можешь прочитать заодно и мои статьи датированными 2012 годом. Не сказать, что они актуальны на все 100%, что в них нет ошибок, но определенную базу они могут дать. Как, наверное, понятно — я буду писать только о 3D: со списком тем я определился не до конца, но думаю, что сформирую их довольно быстро.
Пока точно задумал две статьи:
- “XNA 3D: введение в custom shader и чуть-чуть прототипа”
- “XNA 3D: HDR vs LDR, реализация HDR"
Сейчас сделаю введение в custom shader и реализуем простой прототип игры FEZ.
Введение
Когда мы работали с 2D — мы не заморачивались никакими матрицами, мы просто отдавали наши текстуры и их позицию местному SpriteBatch, и он их нам рисовал. Но хочу сказать: что все, что он рисует — 3D: только одна из координат равна нулю (в XNA — координата Z), ну и используется специальная проекция (о них чуть позже). Проекция — перевод координат из 3D пространства в экранное пространство 2D. Так же, одна из перегрузок метода SpriteBatch поддерживает параметр в виде матрицы: через нее мы делали камеру. И теперь приведем аналогии с 3D. Те координаты, которые мы передавали в SpriteBatch в качестве позиции текстуры (а так же её поворот и размер) — это называется мировой трансформацией (а так же — мировой матрицей). Тот параметр-матрица в SpriteBatch.Begin — видовая матрица. И специальная матрица-проекция, которую мы не можем изменить в SpriteBatch. А теперь еще раз и с другого ракурса: любая модель состоит из точек — именуемых вертексами, вся позиция (трансформация) этих вертексов — локальная система координат. Далее, нам нужно сделать их мировыми, благодаря этому – мы можем использовать одну и ту же модель и нарисовать её в разных местах с разным вращением/размером. После этого мы должны сдвинуть эту трансформацию с учетом камеры. И после того, как мы рассчитали конечные трансформации, мы проецируем их из 3D пространства на экранное 2D.
Графическое устройство: шейдер и модели
Я затрагивал тему шейдеров в прошлых статьях и то, как с помощью них делается пост-процессинг изображения. Использовали мы только пиксельные шейдеры. На деле — все сложнее. Кроме пиксельных шейдеров существуют и вершинные (рассматриваю ситуацию до SM3.0 включительно). Эти шейдеры оперируют вершинами, а не пикселями. Т.е. выполняется для каждой вершины. Тут у нас и происходит магия трансформирования. Давайте попробуем создать новый .fx файл в XNA и разберём его:
float4x4 World;
float4x4 View;
float4x4 Projection;
Первые три строчки как раз наши матрицы. Все эти значения берутся из так называемого константного буфера (о буферах чуть позже). Дальше идет реализация структур входа-выхода:
struct VertexShaderInput
{
float4 Position : POSITION0;
};
struct VertexShaderOutput
{
float4 Position : POSITION0;
};
Это самая простейшая реализация входа-выхода вершинного шейдера: мы получаем позицию вертекса по каналу POSITION0 и в качестве выходных данных — сообщаем информацию для следующей части графического конвейера (уже трансформированные данные) — растеризатору.
Ну и последняя часть .fx файла — сами шейдеры:
VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
VertexShaderOutput output;
float4 worldPosition = mul(input.Position, World);
float4 viewPosition = mul(worldPosition, View);
output.Position = mul(viewPosition, Projection);
return output;
}
float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
return float4(1, 0, 0, 1);
}
Вершинный шейдер получает позицию вертекса в “модельном” пространстве, затем приводит к мировому, видовому и конечном счете к экранному. Ну а пиксельный шейдер заливает все красным цветом.
Чуть-чуть о матрицах
Вдаваться в подробности этих матриц я не буду (подобной информации масса: т.к. она довольно общая): я лишь скажу, как с этим делом обстоит в XNA.
World матрица задается видом Scale Rotation Translation:
Matrix world = Matrix.CreateScale(x, y, z) * Matrix.CreateFromYawPitchRoll(y, p, r) * Matrix.CreateTranslation(x, y, z);
Умножение матриц некоммутативно, поэтому тут важен порядок.
View матрица (матрица камеры):
Matrix view = Matrix.CreateLookAt(vpos, targetpos, up);
Вообще говоря, можно использовать и SRT-матрицу, но в XNA есть удобное средство сделать проще.
Vpos — позиция камеры, targetpos — точка, куда смотрит камера и up — вектор направленный вверх (обычно, Vector3.Up).
Ну и последнее и самое важное — матрица проекции. В самом стандартном случае их две: ортографическая проекция и перспективная. Забегая вперед, ортографическая используется для 2D (и в какой-то степени для изометрии), а перспективная в других случаях (к примеру 3D шутеры).
Наш глаз устроен так, что мы воспринимаем все объекты в перспективной проекции, т.е. чем удалённее объект, тем он кажется нам меньше. Ортогональная проекция с первого взгляда выглядит странно, потому что размер объекта не зависит от расстояния до него. Она похожа на то, если бы мы рассматривали сцену с бесконечно большого расстояния. Именно поэтому она используется для 2D игр, т.к. нам не нужно учитывать расстояние до определенного полигона.
Представление модели
При загрузке модели с жесткого диска — мы загружаем (в простейшем случае) — следующую информацию:
POSITION — позиция вертекса.
NORMAL — нормаль вертекса.
TEXCOORD — текстурную координату (UV-развертку).
Эта информация называется каналами вертекса (как с аналогией Red канала у RGB-пространства).
А так же, загружаем специальную информацию, которая называется индексами. Теперь попробуем это разобрать.
Нам нужно нарисовать квадрат, квадрат состоит из четырех точек. Графическое устройство оперирует только треугольниками. Любую фигуру можно разбить на треугольники. И теперь, если описать этот квадрат для графического устройства нужно будет 6 вертексов (3 на каждый треугольник). Но хочу заменить, что некоторые вертексы (а вернее их позиции) будут в этом случае равны. Для этого был придуман специальный индекс-буфер. Представим, что у нас 4 опорных вертекса, которые описывают квадрат: v1, v2, v3, v4. И теперь можно построить индексный-буфер: [0, 1, 2, 1, 2, 3] — графическое устройство по этим индексам будет находить и использовать вертексы из вертексного буфера: [v1, v2, v3, v2, v3, v4]. Этот подход очень удобный, т.к. сильно снижает объем вертексного буфера, а так же расширяет функционал для рисования. К примеру, можно задать большой вертексный-буфер (крайне медленная операция), а затем рисовать определённые части модели — меняя лишь индексный буфер (быстрая операция в сравнении с установкой вертексного буфера).
В XNA существуют следующие классы: VertexBuffer и IndexBuffer. Создание их прямо в коде мы пока рассматривать не будем, а для этого воспользуемся загрузкой простой модели.
Создадим простую модель и сохраним её в FBX формат:
После чего — мы можем извлечь уже созданные VertexBuffer и IndexBuffer:
_vertexBuffer = boxModel.Meshes[0].MeshParts[0].VertexBuffer;
_indexBuffer = boxModel.Meshes[0].MeshParts[0].IndexBuffer;
Внимание! Подобный случай подходит только для простейшей модели с одним нетрансформированным мешем. У сложной модели может быть несколько вертексных/индексных буферов. Так же, части модели могут иметь собственную трансформацию в пространстве модели.
Реализация
Теперь все готово и давайте сделаем какой-нибудь прототип. Есть такая замечательная инди-игра — FEZ. Многие мои знакомые спрашивали, как такой геймплей возможен с точки зрения реализации? На самом деле нет ничего заумного, просто используется ортогональная (ортографическая) проекция (причем информация о мире содержится в 3D виде, в отличии от классической 2D игры) с возможностью вращать мир.
Чуть-чуть описание геймплея с википедии:
Fez представлен в виде 2D-платформера, в котором Гомес может ходить, прыгать, карабкаться и манипулировать объектами. Тем не менее игрок может в любое время сдвигать перспективы, вращая мир на 90 градусов относительно экрана. Это позволяет обнаруживать двери и проходы, а также заставляет платформы перестраиваться. Поскольку объём не характерен 2D-играм, игрок может (и обязан) воспользоваться этой механикой, чтобы выполнять действия, которые, как правило, невозможны в настоящем 3D-мире. Например, стоя на движущейся платформе и сдвигая перспективу на 90 градусов, Гомес может перейти на другую платформу, которая ранее была на противоположной стороне экрана. Возвращаясь к первоначальной перспективе после перемещения, оказывается, что Гомес переместился на большое расстояние.
И видео самого геймплея:
Начнем!
Загружаем базовую геометрию:
Model boxModel = Content.Load<Model>("simple_cube");
_vertexBuffer = boxModel.Meshes[0].MeshParts[0].VertexBuffer;
_indexBuffer = boxModel.Meshes[0].MeshParts[0].IndexBuffer;
Загружаем две простые 16x16 текстуры:
_simpleTexture1 = Content.Load<Texture2D>("simple_texture1");
_simpleTexture2 = Content.Load<Texture2D>("simple_texture2");
И загружаем ранее созданный шейдер (эффект) (.fx):
_effect = Content.Load<Effect>("simple_effect");
Все это дело мы делаем в методе: LoadContent.
И теперь попробуем нарисовать нашу модель (метод Draw):
// Задаем буферы
GraphicsDevice.SetVertexBuffer(_vertexBuffer);
GraphicsDevice.Indices = _indexBuffer;
// Задаем матрицы
Matrix view = Matrix.CreateLookAt(Vector3.One * 2f, Vector3.Zero, Vector3.Up);
Matrix projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.ToRadians(45f), GraphicsDevice.Viewport.AspectRatio, 0.01f, 100f);
float dt = (float)gameTime.TotalGameTime.TotalSeconds;
Matrix world = Matrix.CreateFromYawPitchRoll(dt, dt, dt);
// Задаем параметры для шейдера
_effect.Parameters["View"].SetValue(view);
_effect.Parameters["Projection"].SetValue(projection);
_effect.Parameters["World"].SetValue(world);
// Устанавливаем активный шейдер
_effect.CurrentTechnique.Passes[0].Apply();
// Рисуем геометрию
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _vertexBuffer.VertexCount, 0, _indexBuffer.IndexCount / 3);
Внимание! Подобный случай подходит только для простейшего шейдера с одним проходом. У сложного шейдера может быть несколько проходов.
Тут мы использовали перспективную проекцию с углом обзора в 45 градусов. Все работает. Теперь, нам нужно задать текстуру нашей модели. При сохранении модели из Cinema 4D в формат FBX — были следующие каналы: POSITION, NORMAL, TEXCOORD (UV). Вернемся к нашему шейдеру и добавим во входные/выходные данные один из каналов:
struct VertexShaderInput
{
float4 Position : POSITION0;
float2 UV : TEXCOORD0;
};
struct VertexShaderOutput
{
float4 Position : POSITION0;
float2 UV : TEXCOORD0;
};
Это будут наши текстурные координаты. Текстурная координата связывает вертекс с позицией на двухмерной текстуре.
И в вершинном шейдере передаем без изменений:
output.UV = input.UV;
После этапа растеризации мы получим интерполированные (по треугольнику) значения TEXCOORD0 и сможем получить значения цвета текстуры в пиксельном шейдере:
float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
return tex2D(TextureSampler, input.UV);
}
Но для того, чтобы получить значения цвета текстуры по UV – нужно задать эту самую текстуру. Для этого существуют сэмплеры, которые кроме самой текстуры содержат информацию о том что делать, если на растеризованном треугольнике текстура на экране получилась слишком большой или маленькой.
Т.к. мы задаем параметры явно в шейдере, сохраним традицию и создадим сэмплер в шейдере:
texture Texture;
sampler2D TextureSampler = sampler_state
{
Texture = <Texture>;
};
Ну и зададим текстуру в качестве параметра:
_effect.Parameters["Texture"].SetValue(_simpleTexture1);
Видим:
Но т.к. текстура на экране получилась больше, чем 16x16 — она была интерполирована в соответствии с настройками сэмплера. Нам это не к чему, поэтому сменим фильтрацию в сэмплере:
texture Texture;
sampler2D TextureSampler = sampler_state
{
Texture = <Texture>;
MipFilter = POINT;
MinFilter = POINT;
MagFilter = POINT;
};
Кстати, о фильтрации я говорил в статьях ранее.
Теперь все настроено и пора совместить наше 2D и 3D. Введем класс Block:
public class Block
{
public enum BlockType { First, Second }
public Matrix Transform;
public BlockType Type;
}
Где Transform — наша мировая матрица для объекта.
И простую генерацию этих блоков:
private void _createPyramid(Vector3 basePosition, int basesize, int baseheight, Block.BlockType type)
{
for (int h = 0; h < baseheight; h++)
{
int size = basesize - h * 2;
for (int i = 0; i < size; i++)
for (int j = 0; j < size; j++)
{
Block block = new Block();
Vector3 position = new Vector3(
-(float)size / 2f + (float)i,
(float)h,
-(float)size / 2f + (float)j) + basePosition;
block.Transform =
Matrix.CreateTranslation(position);
block.Type = type;
_blocks.Add(block);
}
}
}
Ну и возможность рисовать множество моделей с разными трансформациями:
GraphicsDevice.SetVertexBuffer(_vertexBuffer);
GraphicsDevice.Indices = _indexBuffer;
Matrix view = Matrix.CreateLookAt(Vector3.One * 10f, Vector3.Zero, Vector3.Up);
Matrix projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.ToRadians(45f), GraphicsDevice.Viewport.AspectRatio, 0.01f, 100f);
_effect.Parameters["View"].SetValue(view);
_effect.Parameters["Projection"].SetValue(projection);
foreach (Block block in _blocks)
{
Matrix world = block.Transform;
_effect.Parameters["Texture"].SetValue(_simpleTexture1);
_effect.Parameters["World"].SetValue(world);
_effect.CurrentTechnique.Passes[0].Apply();
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _vertexBuffer.VertexCount, 0, _indexBuffer.IndexCount / 3);
}
И последний этап — создадим наш мир:
_createPyramid(new Vector3(-7f, 0f, 5.5f), 10, 5, Block.BlockType.First);
_createPyramid(new Vector3(7f, 0f, 0f), 5, 3, Block.BlockType.Second);
_createPyramid(new Vector3(7f, -7f, 7f), 7, 10, Block.BlockType.First);
Осталось самое важное, это создание нужной проекции и возможность вращать наш мир.
Проекцию мы будем задавать следующим образом:
Matrix projection = Matrix.CreateOrthographic(20f * GraphicsDevice.Viewport.AspectRatio, 20f, -100f, 100f);
Где 20f — своеобразный “зум”. А -100f и 100f ближняя и дальняя грань отсечения.
А видовую:
Matrix view =
Matrix.CreateRotationY(MathHelper.PiOver2 * _rotation);
Где _rotation — вращение. Для “мягкого” вращения можно использовать функцию MathHelper.SmoothStep, это ничто иное, как кубический lerp.
Для вращения введем четыре переменные:
/* ROTATION */
float _rotation;
float _rotationTo;
float _rotationFrom;
float _rotationDelta;
И обновим наш Update:
if (keyboardState.IsKeyDown(Keys.Left) && _prevKeyboardState.IsKeyUp(Keys.Left) && _rotationDelta >= 1f)
{
_rotationFrom = _rotation;
_rotationTo = _rotation - 1f;
_rotationDelta = 0f;
}
if (keyboardState.IsKeyDown(Keys.Right) && _prevKeyboardState.IsKeyUp(Keys.Right) && _rotationDelta >= 1f)
{
_rotationFrom = _rotation;
_rotationTo = _rotation + 1f;
_rotationDelta = 0f;
}
if (_rotationDelta <= 1f)
{
_rotationDelta += (float)gameTime.ElapsedGameTime.TotalSeconds * 2f;
_rotation = MathHelper.SmoothStep(_rotationFrom, _rotationTo, _rotationDelta);
}
Обращу внимание, что тут используется gameTime.ElapsedGameTime: в моменты, когда изменения какой-либо переменной в игре происходит постепенно — необходимо учитывать это значение, т.к. FPS может быть у всех разным.
Ну и напоследок, немного слов о реализации мира. Можно генерировать мир (физический) на каждое вращение при загрузке уровня и при вращении проверять, способен ли игрок перейти на нужный уровень с текущей позиции, и в какую именно позицию он перейдет.
Комментарий по прототипу
Конечно, в этом прототипе есть масса проблем: например, грани, которые мы никогда не увидим (если бы я писал про динамическое построение геометрии, то статья выросла бы в разы). Кроме того, все это дело — можно реализовать и без custom shader, использовав BasicEffect. Но для будущих статей важно понимание того, как делаются custom shader без привязывания к модели.
Исходный код + бинарник: тут
Заключение
Этой статьей я сделал некоторое введение и показал прототип известной игры, где в качестве проекции используется не перспектива. После этого введения я планирую объяснить некоторые фишки разработки игр в трех измерениях, попробую довольно подробно познакомить с шейдерами (как с пиксельными, так и с вершинными). Так же ознакомить с некоторыми методиками, такими как HDR, Deferred Rendering (я раньше делал подобное для 2D, но тот метод скорее модифицированный Forward-рендеринг, чем Deferred-методика), VTF. Ну и так как — я публикуюсь исключительно на хабрахабре, то всегда рад комментариям-предложениям: если вы не знаете — как что устроено в какой-то игре (особенно приветствуются игры ААА-класса), или вам интересна реализация того или иного эффекта, то можете смело писать комментарий. Я попробую максимально доступно рассказать об этом.
P.S. все мы люди и делаем ошибки и поэтому, если найдете ошибку в тексте — напишите мне личным сообщением, а не спешите писать гневный комментарий!
Автор: ForhaxeD