[Перевод] Разработка трехмерных игр для Windows 8 с помощью C++ и Microsoft DirectX
Разработка игр — постоянно актуальная тема: всем нравится играть в игры, их охотно покупают, поэтому их выгодно продавать. Но при разработке хороших игр следует обращать немало внимания на производительность. Никому не понравится игра, «тормозящая» или работающая рывками даже на не самых мощных устройствах.В этой статье я покажу, как разработать простую футбольную 3D игру с использованием Microsoft DirectX и C++, хотя главным образом я занимаюсь разработкой на C#. В прошлом я довольно много работал с C++, но теперь этот язык для меня уже не столь прост. Кроме того, DirectX для меня является новинкой, поэтому эту статью можно считать точкой зрения новичка на разработку игр. Прошу опытных разработчиков простить меня за возможные ошибки.Мы будем использовать пакет Microsoft Visual Studio 3D Starter Kit — естественный начальный ресурс для всех желающих разрабатывать игры для Windows 8.1.
Microsoft Visual Studio 3D Starter Kit После загрузки пакета Starter Kit можно распаковать его в папку и открыть файл StarterKit.sln. В этом решении есть уже готовый проект C++ для Windows 8.1. При его запуске появится изображение, похожее на рис. 1.Рисунок 1. Начальное состояние Microsoft Visual Studio 3D Starter Kit
Эта программа в составе Starter Kit демонстрирует несколько полезных элементов.
Анимировано пять объектов: четыре фигуры вращаются вокруг чайника, а чайник, в свою очередь, «танцует». Каждый предмет сделан из отдельного материала; некоторые имеют сплошной цвет, а поверхность куба представляет собой растровый рисунок. Источник света находится в верхнем левом углу сцены. В правом нижнем углу экрана расположен счетчик кадровой скорости (количество кадров в секунду). Сверху находится индикатор очков. Если щелкнуть какой-либо предмет, он выделяется и увеличивается количество очков. Если щелкнуть экран игры правой кнопкой мыши или провести по экрану от нижнего края к середине, появятся две кнопки для последовательного переключения цвета чайника. Основной цикл игры находится в файле StarterKitMain.cpp, где отрисовывается страница и счетчик кадровой скорости. Game.cpp содержит игровой цикл и проверку нажатий. В этом файле в методе Update вычисляется анимация, а в методе Render происходит отрисовка всех объектов. Счетчик кадровой скорости отрисовывается в SampleFpsTextRenderer.cpp. Объекты игры находятся в папке Assets. Teapot.fbx — это чайник, а файл GameLevel.fbx содержит четыре фигуры, которые вращаются вокруг танцующего чайника.Теперь, ознакомившись с образцом приложения в пакете Starter Kit, можно перейти к созданию собственной игры.
Добавление ресурсов в игру Мы разрабатываем игру в футбол, поэтому самым первым нашим ресурсом должен быть футбольный мяч, который мы добавим в Gamelevel.fbx. Сначала нужно удалить из этого файла четыре фигуры, выделив каждую из них и нажав кнопку Delete. В обозревателе решений удалите и файл CubeUVImage.png, поскольку он нам не нужен: это текстура для куба, который мы только что удалили.Теперь добавляем сферу в модель. Откройте инструменты (если их не видно, щелкните View > Toolbox) и дважды щелкните сферу, чтобы добавить ее в модель. Нам также требуется растянутая текстура, такая как на рис. 2.Рисунок 2. Текстура футбольного мяча, приспособленная к сфере
Если вы не хотите создавать собственные модели в Visual Studio, можно найти готовые модели в Интернете. Visual Studio поддерживает любые модели в формате FBX, DAE и OBJ: достаточно добавить их в состав ресурсов решения. Например, можно использовать файл .obj, подобный показанному на рис. 3 (бесплатная модель с сайта TurboSquid).
Рисунок 3. Трехмерная OBJ-модель мяча
Анимация модели Модель готова, теперь пора ее анимировать. Но сначала нужно убрать чайник, поскольку он нам не понадобится. В папке Assets удалите файл teapot.fbx. Теперь удалите его загрузку и анимацию. В файле Game.cpp загрузка моделей происходит асинхронно в CreateDeviceDependentResources: Код // Load the scene objects. auto loadMeshTask = Mesh: LoadFromFileAsync ( m_graphics, L«gamelevel.cmo», L», L», m_meshModels) .then ([this]() { // Load the teapot from a separate file and add it to the vector of meshes. return Mesh: LoadFromFileAsync ( Нужно изменить модель и удалить продолжение задачи, чтобы загружался только мяч: Код void Game: CreateDeviceDependentResources () { m_graphics.Initialize (m_deviceResources→GetD3DDevice (), m_deviceResources→GetD3DDeviceContext (), m_deviceResources→GetDeviceFeatureLevel ());
// Set DirectX to not cull any triangles so the entire mesh will always be shown. CD3D11_RASTERIZER_DESC d3dRas (D3D11_DEFAULT); d3dRas.CullMode = D3D11_CULL_NONE; d3dRas.MultisampleEnable = true; d3dRas.AntialiasedLineEnable = true;
ComPtr
// Load the scene objects. auto loadMeshTask = Mesh: LoadFromFileAsync ( m_graphics, L«gamelevel.cmo», L», L», m_meshModels);
(loadMeshTask).then ([this]() { // Scene is ready to be rendered. m_loadingComplete = true; }); } Методу ReleaseDeviceDependentResources нужно лишь очистить сетки: Код void Game: ReleaseDeviceDependentResources () { for (Mesh* m: m_meshModels) { delete m; } m_meshModels.clear ();
m_loadingComplete = false;
}
Теперь нужно изменить метод Update, чтобы вращался только мяч: Код
void Game: Update (DX: StepTimer const& timer)
{
// Rotate scene.
m_rotation = static_cast
auto context = m_deviceResources→GetD3DDeviceContext ();
// Set render targets to the screen. auto rtv = m_deviceResources→GetBackBufferRenderTargetView (); auto dsv = m_deviceResources→GetDepthStencilView (); ID3DllRenderTargetView *const targets[1] = { rtv }; context→OMSetRenderTargets (1, targets, dsv);
// Draw our scene models. XMMATRIX rotation = XMMatrixRotationY (m_rotation); for (UINT i = 0; i < m_meshModels.size() ; i++) { XMMATRIX modelTransform = rotation; String^ meshName = ref new String (m_meshModels [i]->Name ()) ; m_graphics.UpdateMiscConstants (m_miscConstants);
m_meshModels[i]→Render (m_graphics, modelTransform); } } ToggleHitEffect здесь не будет работать: свечение мяча не изменится при его нажатии. void Game: ToggleHitEf feet (String^ object) {
} Нам не нужно, чтобы изменялась подсветка мяча, но нужно получать данные о его касании. Для этого используем измененный метод onHitobject: Код String^ Game: : OnHitobject (int x, int y) { String^ result = nullptr;
XMFLOAT3 point; XMFLOAT3 dir; m_graphics.GetCamera ().GetWorldLine (x, y, &point, &dir); XMFLOAT4×4 world; XMMATRIX worldMat = XMMatrixRotationY (m_rotation); XMStoreFloat4×4(&world, worldMat); float closestT = FLT_MAX; for (Mesh* m: m_meshModels) { XMFLOAT4×4 meshTransform = world;
auto name = ref new String (m→Name ()); float t = 0; bool hit = HitTestingHelpers: LineHitTest (*m, &point, &dir, SmeshTransform, &t); if (hit && t < closestT) { result = name; } }
return result; }
Если сейчас запустить проект, вы увидите, что мяч вращается вокруг своей оси Y. Теперь приведем мяч в движение.Движение мяча
Чтобы мяч двигался, нужно перемещать его, например, вверх и вниз. Сначала нужно объявить переменную для текущего положения мяча в Game.h: Код
class Game
{
public:
// snip private:
// snip
float m_translation;
Затем в методе Update нужно вычислить текущее положение: Код
void Game: Update (DX: StepTimer consts timer)
{
// Rotate scene.
m_rotation = static_cast
String^ meshName = ref new String (m_meshModels[i]→Name ());
m_graphics.UpdateMiscConstants (m_miscConstants); if (String: CompareOrdinal (meshName, L«Sphere_Node») == 0) m_meshModels[i]→Render (m_graphics, modelTransform); else m_meshModels[i]→Render (m_graphics, XMMatrixIdentity ()); } } При этом изменении преобразование применяется только к мячу. Поле отрисовывается без преобразования. Если запустить код сейчас, вы увидите, что мяч отскакивает от поля, но «проваливается» в него в нижней части. Для исправления этой ошибки нужно перенести поле на -0,5 по оси Y. Выберите поле и измените его перенос по оси Y на -0,5. Теперь при запуске приложения мяч будет отскакивать от поля, как на рис. 4.Рисунок 4. Мяч отскакивает от поля
Задание положения камеры и мяча Мяч расположен в центре поля, но нам он там не нужен. В этой игре мяч должен находиться на 11-метровой отметке. Следует переместить мяч по оси X, изменив перемещение мяча в методе Render в Game.cpp: rotation *= XMMatrixTranslation (63.0, m_translation, 0); Мяч перемещается на 63 единицы по оси X, то есть устанавливается на 11-метровую отметку. После этого изменения вы перестанете видеть мяч, поскольку он вне поля зрения камеры: камера установлена в центре поля и направлена на середину. Нужно изменить положение камеры, чтобы она была направлена на линию ворот. Это нужно сделать в CreateWindowSizeDependentResources в файле Game.cpp: Код m_graphics.GetCamera ().SetViewport ((UINT) outputSize.Width, (UINT) outputSize.Height); m_graphics.GetCamera ().SetPosition (XMFLOAT3(25.Of, 10.0f, 0.0f)); m_graphics.GetCamera ().SetLookAt (XMFLOAT3(100.0f, 0.0f, 0.0f)); float aspectRatio = outputSize.Width / outputSize.Height; float fovAngleY = 30.0f * XM_PI / 180.0f; if (aspectRatio < 1.0f) { // Portrait or snap view m_graphics.GetCamera().SetUpVector(XMFLOAT3(1.0f, 0.0f, 0.0f)); fovAngleY = 120.0f * XM_PI / 180.0f; } else { // Landscape view. m_graphics.GetCamera().SetUpVector(XMFLOAT3(0.0f, 1.0f, 0.0f)); } m_graphics.GetCamera().SetProjection(fovAngleY, aspectRatio, 1.0f, 100.0f); Теперь камера находится между отметкой середины поля и 11-метровой отметкой и направлена в сторону линии ворот. Новое представление показано на рис. 5.Рисунок 5. Измененное положение мяча и новое положение камеры
Добавление штанги ворот Чтобы добавить на поле ворота, понадобится новая трехмерная сцена с воротами. Можно создать собственную модель или использовать готовую. Эту модель следует добавить в папку Assets, чтобы ее можно было скомпилировать и использовать.Эту модель нужно загрузить в методе CreateDeviceDependentResources в файле Game.cpp: Код auto loadMeshTask = Mesh: LoadFromFileAsync ( m_graphics, L«gamelevel.cmo», L», L», m_meshModels) .then ([this]() { return Mesh: LoadFromFileAsync ( m_graphics, L«field.cmo», L», L», m_meshModels, false // Do not clear the vector of meshes ); }).then ([this]() { return Mesh: LoadFromFileAsync ( m_graphics, L«soccer_goal.cmo», L», L», m_meshModels, false // Do not clear the vector of meshes ); }); После загрузки задайте положение и отрисуйте в методе Render в Game.cpp: Код auto goalTransform = XMMatrixScaling (2.0f, 2.0f, 2.0f) * XMMatrixRotationY (-XM_PIDIV2)* XMMatrixTranslation (85.5f, -0.5, 0); for (UINT i = 0; i < m_meshModels.size() ; i++) { XMMATRIX modelTransform = rotation; String'^ meshName = ref new String (m_meshModels [i]->Name ()) ; m_graphics.UpdateMiscConstants (m_miscConstants); if (String: CompareOrdinal (meshName, L«Sphere_Node») == 0) m_meshModels[i]→Render (m_graphics, modelTransform); else if (String: CompareOrdinal (meshName, L«Plane_Node») == 0) m_meshModels[i]→Render (m_graphics, XMMatrixIdentity ()); else m_meshModels[i]→Render (m_graphics, goalTransform); } Это изменение применяет преобразование к воротам и отрисовывает их. Это преобразование является сочетанием трех преобразований: масштабированием (увеличение исходного размера в 2 раза), поворотом на 90 градусов и перемещением на 85,5 единиц по оси X и на -0,5 единиц по оси Y из-за глубины поля. После этого ворота устанавливаются лицом к полю на линии ворот. Обратите внимание, что важен порядок преобразований: если применить вращение после перемещения, то ворота будут отрисованы совсем в другом месте, и вы их не увидите.Удар по мячу Все элементы установлены на свои места, но мяч все еще подпрыгивает. Пора по нему ударить. Для этого нужно снова применить физические навыки. Удар по мячу выглядит примерно так, как показано на рис. 6.Рисунок 6. Схема удара по мячу
Удар по мячу осуществляется с начальной скоростью v0 под углом α (если не помните школьные уроки физики, поиграйте немного в Angry Birds, чтобы увидеть этот принцип в действии). Движение мяча можно разложить на два разных движения: по горизонтали — это движение с постоянной скоростью (исходим из того, что отсутствует сопротивление воздуха и воздействие ветра), а также вертикальное движение — такое же, как мы использовали раньше. Уравнение движения по горизонтали: sX = s0 + v0*cos (α)*tУравнение движения по вертикали: sY = s0 + v0*sin (α)*t — ½*g*t2Таким образом, у нас два перемещения: одно по оси X, другое по оси Y. Если удар нанесен под углом 45 градусов, то cos (α) = sin (α) = sqrt (2)/2, поэтому v0*cos (α) = v0*sin (a)*t. Нужно, чтобы мяч попал в ворота, поэтому дальность удара должна превышать 86 единиц (расстояние до линии ворот равно 85,5). Нужно, чтобы полет мяча занимал 2 секунды. При подстановке этих значений в первое уравнение получим:86 = 63 + v0* cos (α) * 2 >= v0* cos (α) = 23/2 = 11,5Если заменить значения в уравнении, то уравнение перемещения по оси Y будет таким: sY = 0 + 11,5*t-5*t2А по оси X — таким: sX = 63 + 11,5*tУравнение для оси Y дает нам время, когда мяч снова ударится о землю. Для этого нужно решить квадратное уравнение (да, я понимаю, что вы надеялись навсегда распрощаться с ними после школьного курса алгебры, но тем не менее вот оно):(-b ± sqrt (b0 — 4*a*c))/2*a >= (-11,5 ± sqrt (11,52 — 4 * -5×0)/2 * -5 >= 0 или 23/10 >= 2,3 сЭтими уравнениями можно заменить перемещение для мяча. Сначала в Game.h создайте переменные для сохранения перемещения по трем осям: float m_translationX, m_translationY, m_translationZ; Затем в методе Update в Game.cpp добавьте уравнения:
Код
void Game: Update (DX: StepTimer consts timer)
{
// Rotate scene.
m_rotation = static_cast
Код
void Game: Update (DX: StepTimer consts timer)
{
// Rotate scene.
m_rotation = static_cast
Добавление вратаря Движение мяча уже готово, ворота на месте, теперь нужно добавить вратаря, который будет ловить мяч. В роли вратаря у нас будет искаженный куб. В папке Assets добавьте новый элемент (новую трехмерную сцену) и назовите его goalkeeper.fbx.Добавьте куб из набора инструментов и выберите его. Задайте масштаб: 0,3 по оси X, 1,9 по оси Y и 1 по оси Z. Для свойства MaterialAmbient установите значение 1 для красного цвета и значение 0 для синего и зеленого цвета, чтобы сделать объект красным. Измените значение свойства Red в разделе MaterialSpecular на 1 и значение свойства MaterialSpecularPower на 0,2.Загрузите новый ресурс в методе CreateDeviceDependentResources: Код auto loadMeshTask = Mesh: LoadFromFileAsync ( m_graphics, L«gamelevel.cmo», L», L», m_meshModels) .then ([this]() { return Mesh: LoadFromFileAsync ( m_graphics, L«field.cmo», L», L», m_meshModels, false // Do not clear the vector of meshes ); }).then ([this]() { return Mesh: LoadFromFileAsync ( m_graphics, L«soccer_goal.cmo», L», L», m_meshModels, false // Do not clear the vector of meshes ); }).then ([this]() { return Mesh: LoadFromFileAsync ( m_graphics, L«goalkeeper.cmo», L», L», m_meshModels, false // Do not clear the vector of meshes ); }); Теперь нужно расположить вратаря в середине ворот и отрисовать его. Это нужно сделать в методе Render в Game.cpp: Код void Game: Render () { // snip auto goalTransform = XMMatrixScaling (2.0f, 2.0f, 2.0f) * XMMatrixRotationY (-XM_PIDIV2)* XMMatrixTranslation (85.5f, -0.5f, 0); auto goalkeeperTransform = XMMatrixTranslation (85.65f, 1.4f, 0) ; for (UINT i = 0; i < m_meshModels.size(); i++) { XMMATRIX modelTransform = rotation;
String^ meshName = ref new String (m_meshModels [i]→Name ()) ;
m_graphics.UpdateMiscConstants (m_miscConstants);
if (String: CompareOrdinal (meshName, L«Sphere_Node») == 0)
m_meshModels[i]→Render (m_graphics, modelTransform);
else if (String: CompareOrdinal (meshName, L«Plane_Node») == 0)
m_meshModels[i]→Render (m_graphics, XMMatrixIdentity ());
else if (String: CompareOrdinal (meshName, L«Cube_Node») == 0)
m_meshModels[i]→Render (m_graphics, goalkeeperTransform);
else
m_meshModels[i]→Render (m_graphics, goalTransform);
}
}
Этот код размещает вратаря в середине ворот. Теперь нужно сделать так, чтобы вратарь мог перемещаться влево и вправо, чтобы ловить мяч. Для управления движением вратаря пользователь будет нажимать на клавиши со стрелками влево и вправо.Движение вратаря ограничено штангами ворот, расположенными на расстоянии +7 и -7 единиц по оси Z. Ширина вратаря составляет 1 единицу в каждую сторону, поэтому он может перемещаться на 6 единиц влево или вправо.Нажатие клавиши перехватывается на странице XAML (Directxpage.xaml) и перенаправляется в класс Game. Добавляем обработчик событий KeyDown в Directxpage.xaml: Код
// Public methods passed straight to the Game renderer.
Platform: : String'^ OnHitObject (int x, int y) {
return m_sceneRenderer→OnHitObject (x, y); }
void OnKeyDown (Windows: System: VirtualKey key) {
m_sceneRenderer→OnKeyDown (key); }
… .
Этот метод перенаправляет клавишу методу OnKeyDown в классе Game. Теперь нужно объявить метод OnKeyDown в файле Game.h: Код
class Game
{ public:
Gamefconst std: shared_ptr
До сих пор мяч двигался постоянно, но это нам не нужно. Мяч должен начинать движение непосредственно после удара и останавливаться при достижении ворот. Вратарь также не должен двигаться до удара по мячу.Необходимо объявить частное поле m_isAnimating в файле Game.h, чтобы игра «знала», когда мяч движется:
Код
class Game
{
public:
// snip
private:
// snip
bool m_isAnimating;
Эта переменная используется в методах Update и Render в Game.cpp, поэтому мяч перемещается, только когда m_isAnimating имеет значение true: Код
void Game: Update (DX: StepTimer consts timer)
{
if (m_isAnimating)
{
m_rotation = static_cast
if (totalTime > 2.3f) ResetGame (); } } Объявляем два частных поля в файле Game.h: m_isGoal и m_IsCaught. Эти поля говорят нам о том, что произошло: пользователь забил гол или вратарь поймал мяч. Если оба поля имеют значение false, мяч еще летит. Когда мяч достигает вратаря, программа вычисляет границы мяча и вратаря и определяет, налагаются ли границы мяча на границы вратаря. Если посмотрите в код, то увидите, что я добавил 7.0 f к каждой границе. Я сделал это, поскольку границы могут быть положительными или отрицательными, а это усложнит вычисление наложения. Добавив 7.0 f, я добился того, что все значения стали положительными, чтобы упростить вычисление. Если мяч пойман, его положение устанавливается по центру вратаря. m_isGoal и m_IsCaught сбрасываются при ударе.Итак, мы получили вполне рабочий вариант игры. Желающих продолжить ее совершенствовать, в частности, добавить ведение счета или сенсорное управление, отсылаем к исходному материалу на IDZ.
Заключение Итак, дело сделано. От танцующего чайника мы пришли к игре на DirectX. Языки программирования становятся все более похожими, поэтому использование C++/DX не вызвало особых затруднений у разработчика, привыкшего пользоваться C#.Основное затруднение состоит в освоении трехмерных моделей, в их движении и расположении привычным образом. Для этого потребовалось применить знания физики, геометрии, тригонометрии и математики.Как бы то ни было, можно заключить, что разработка игры не является непосильной задачей. При наличии терпения и нужных инструментов можно создать великолепные игры с превосходной производительностью.