Разработка игр — постоянно актуальная тема: всем нравится играть в игры, их охотно покупают, поэтому их выгодно продавать. Но при разработке хороших игр следует обращать немало внимания на производительность. Никому не понравится игра, «тормозящая» или работающая рывками даже на не самых мощных устройствах.
В этой статье я покажу, как разработать простую футбольную 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<ID3D11RasterizerState> p3d3RasState;
m_deviceResources->GetD3DDevice()->CreateRasterizerState(&d3dRas, &p3d3RasState);
m_deviceResources->GetD3DDeviceContext()->RSSetState(p3d3RasState.Get());
// 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<float>(timer.GetTotalSeconds()) * 0.5f;
}
Для управления скоростью вращения используется множитель (0.5f). Чтобы мяч вращался быстрее, нужно просто увеличить этот множитель. За каждую секунду мяч будет поворачиваться на угол 0,5/(2 * Пи) радиан. Метод Render отрисовывает мяч с нужным углом вращения:
void Game::Render()
{
// Loading is asynchronous. Only draw geometry after it's loaded.
if (!m_loadingComplete)
{
return;
}
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);
XMFLOAT4X4 world;
XMMATRIX worldMat = XMMatrixRotationY(m_rotation);
XMStoreFloat4x4(&world, worldMat);
float closestT = FLT_MAX;
for (Mesh* m : m_meshModels) {
XMFLOAT4X4 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<float>(timer.GetTotalSeconds()) * 0.5f;
const float maxHeight = 7. Of;
auto totalTime = (float) fmod(timer.GetTotalSeconds(), 2.0f);
m_translation = totalTime > 1.0f ?
maxHeight - (maxHeight * (totalTime - 1.0f)) : maxHeight *totalTime;
}
Теперь мяч будет подниматься и опускаться каждые 2 секунды. В течение первой секунды мяч будет подниматься, в течение следующей секунды — опускаться. Метод Render вычисляет получившуюся матрицу и отрисовывает мяч в новом положении:
void Game::Render() {
// snip
// Draw our scene models.
XMMATRIX rotation = XMMatrixRotationY(m_rotation);
rotation *= XMMatrixTranslation(0, m_translation, 0);
Если сейчас запустить проект, вы увидите, что мяч движется вверх и вниз с постоянной скоростью. Теперь нужно придать мячу физические свойства.
Добавление физики мяча
Чтобы придать мячу физические эффекты, нужно сымитировать воздействие на него силы, представляющей гравитацию. Если вы помните школьный курс физики, то знаете, что ускоренное движение тела описывается следующими уравнениями:
s = s0 + v0t + 1/2at2
v = v0 + at
Где s — положение тела в момент t, s0 — начальное положение, v0 — начальная скорость, a — ускорение. Для движения по вертикали a — ускорение свободного падения (-10 м/с2), а s0 = 0 (сначала мяч находится на земле, то есть на нулевой высоте). Уравнения превращаются в следующие:
s = v0t-5t2
v = v0-10t
Мы хотим достигнуть максимальной высоты за 1 секунду. На максимальной высоте скорость равна 0. Поэтому второе уравнение позволяет найти начальную скорость:
0 = v0 — 10 * 1 => v0 = 10 м/с
Это дает нам перемещение мяча:
s = 10t — 5t2
Нужно изменить метод Update, чтобы использовать это уравнение:
void Game::Update(DX::StepTimer consts timer) {
// Rotate scene.
m_rotation = static_cast<float>(timer.GetTotalSeconds()) * 0.5f;
auto totalTime = (float) fmod(timer.GetTotalSeconds(), 2.0f);
m_translation = 10*totalTime - 5 *totalTime*totalTime;
}
Теперь, когда мяч реалистично движется вверх и вниз, пора добавить футбольное поле.
Добавление футбольного поля
Чтобы добавить футбольное поле, нужно создать новую сцену. В папке Assets щелкните правой кнопкой мыши, чтобы добавить новую трехмерную сцену, и назовите ее field.fbx. Из набора инструментов добавьте плоскость и выберите ее, измените ее размер по оси X на 107, а по оси Z на 60. Задайте для свойства этой плоскости Texture1 изображение футбольного поля.
Теперь можно использовать средство масштабирования (или нажимать клавишу Z) для уменьшения изображения.
Затем нужно загрузить модель в CreateDeviceDependentResources в Game.cpp:
void Game::CreateDeviceDependentResources() {
// snip
// Load the scene objects.
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
);
});
(loadMeshTask) .then([this] ()
{
// Scene is ready to be rendered.
m_loadingComplete = true;
});
}
При запуске программы вы увидите, что поле прыгает вместе с мячом. Чтобы поле перестало двигаться, нужно изменить метод Render:
// Renders one frame using the Starter Kit helpers,
void Game::Render()
{
// snip
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
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 — 1/2*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<float>(timer.GetTotalSeconds()) * 0.5f;
auto totalTime = (float) fmod(timer.GetTotalSeconds(), 2.3f);
m_translationX = 63.0 + 11.5 * totalTime;
m_translationY = 11.5 * totalTime - 5 * totalTime*totalTime;
}
Метод Render использует эти новые перемещения:
rotation *= XMMatrixTranslation(m_translationX, m_translationY, 0);
Если запустить программу сейчас, вы увидите, как мяч влетает в середину ворот. Если нужно, чтобы мяч двигался в других направлениях, нужно добавить горизонтальный угол удара. Для этого мы используем перемещение по оси Z.
Расстояние от 11-метровой отметки до ворот составляет 22,5 единицы, а расстояние между штангами ворот — 14 единиц. Это дает нам угол α = atan(7/22.5), то есть 17 градусов. Можно вычислить и перемещение по оси Z, но можно сделать и проще: мяч должен переместиться до линии в тот же момент, когда он достигнет штанги. Это означает, что мяч должен переместиться на 7/22,5 единицы по оси Z и на 1 единицу по оси X. Уравнение для оси Z будет таким:
sz = 11,5 * t/3,2 ≥ sz = 3,6 * t
Это перемещение до штанги ворот. У любого перемещения с меньшей скоростью угол будет меньше. Чтобы мяч достиг ворот, скорость должна составлять от -3,6 (левая штанга) до 3,6 (правая штанга). Если учесть, что мяч должен полностью попасть в ворота, максимальное расстояние составляет 6/22,5, а скорость — от 3 до -3. Имея эти цифры, можно задать угол удара в методе Update:
void Game::Update(DX::StepTimer consts timer)
{
// Rotate scene.
m_rotation = static_cast<float>(timer.GetTotalSeconds()) * 0.5f;
auto totalTime = (float) fmod(timer.GetTotalSeconds(), 2.3f);
m_translationX = 63.0 + 11.5 * totalTime;
m_translationY = 11.5 * totalTime - 5 * totalTime*totalTime;
m_translationZ = 3 * totalTime;
}
Перемещение по оси Z будет использовано в методе Render:
rotation *= XMMatrixTranslation(m_translationX, m_translationY, m_translationZ);
… .
Результат должен быть примерно таким, как на рис. 7.
Увеличить
Рисунок 7. Удар под углом