使用 C++ 和 Microsoft DirectX* 为 Windows* 8 开发 3D 游戏






4.94/5 (9投票s)
在本文中,我将向您展示如何开发一款点球大战游戏。
引言
游戏开发是一个永恒的热门话题:人人都喜欢玩游戏,它们是所有榜单上的畅销品。但是当谈到开发一款好游戏时,性能始终是必需的。没有人喜欢玩卡顿或有故障的游戏,即使是在低端设备上也是如此。
您可以使用许多语言和框架来开发游戏,但是当您想要为 Windows* 游戏提供性能时,没有什么能比得上真正的东西: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
文件。此解决方案有一个准备运行的 Windows 8.1 C++ 项目。如果运行它,您将看到类似图 1 的内容。
这个 Starter Kit 程序演示了几个有用的概念
- 有五个对象在动画:四个形状围绕茶壶旋转,茶壶“跳舞”。
- 每个元素都有不同的材质:有些是纯色,立方体有位图材质。
- 光线来自场景的左上方。
- 右下角包含一个每秒帧数 (FPS) 计数器。
- 顶部有一个分数指示器。
- 点击一个对象会突出显示它,并且分数会增加。
- 右键单击游戏或从底部滑动会调出底部应用栏,其中包含两个按钮,用于循环切换茶壶的颜色。
您可以使用这些功能创建任何游戏,但首先您需要查看工具包中的文件。
让我们从 App.xaml
及其 cpp/h
对应文件开始。当您启动 App.xaml
应用程序时,它会运行 DirectXPage
。在 DirectXPage.xaml
中,您有一个 SwapChainPanel
和应用栏。SwapChainPanel
是 XAML 页面上 DirectX 图形的宿主表面。您可以在其中添加将与 Microsoft Direct3D* 场景一起呈现的 XAML 对象——这对于在 DirectX 游戏中添加按钮、标签和其他 XAML 对象而无需从头创建自己的控件非常方便。Starter Kit 还添加了一个 StackPanel
,您将把它用作记分板。
DirectXPage.xaml.cpp
包含变量的初始化、调整大小和更改方向事件处理程序的挂钩、应用栏按钮点击事件的处理程序以及渲染循环。所有 XAML 对象都像任何其他 Windows 8 程序一样处理。该文件还处理 Tapped
事件,检查点击(或鼠标点击)是否击中了一个对象。如果击中,该事件会增加该对象的分数。
您必须告诉程序 SwapChainPanel
应该渲染 DirectX 内容。要做到这一点,根据文档,您必须“将 SwapChainPanel
实例转换为 IInspectable
或 IUnknown
,然后调用 QueryInterface
获取 ISwapChainPanelNative
接口的引用(这是 SwapChainPanel
的补充的本机接口实现,并启用互操作桥)。然后,在该引用上调用 ISwapChainPanelNative::SetSwapChain
,将您实现的交换链与 SwapChainPanel
实例关联起来。”这在 DeviceResources.cpp
中的 CreateWindowSizeDependentResources
方法中完成。
主游戏循环在 StarterKitMain.cpp
中,其中渲染页面和 FPS 计数器。
Game.cpp
包含游戏循环和命中测试。它在 Update
方法中计算动画,并在 Render
方法中绘制所有对象。FPS 计数器在 SampleFpsTextRenderer.cpp
中渲染。
游戏对象位于 Assets
文件夹中。Teapot.fbx
包含茶壶,GameLevel.fbx
包含围绕跳舞茶壶旋转的四个形状。
有了对 Starter Kit 示例应用程序的这些基本知识,您就可以开始创建自己的游戏了。
向游戏添加资源
您正在开发一款足球游戏,所以您需要的第一个资源是足球,您将其添加到 Gamelevel.fbx
中。首先,通过选择并按 Delete 键从该文件中删除四个形状。在解决方案资源管理器中,也删除 CubeUVImage.png
,因为您不需要它;它是用于覆盖您刚刚删除的立方体的纹理。
下一步是向模型添加一个球体。打开工具箱(如果看不到,请单击 视图 > 工具箱)并双击球体将其添加到模型中。如果球看起来太小,您可以通过单击编辑器顶部工具栏中的第二个按钮,按 Z 键用鼠标缩放(拖动到中心增加缩放),或点击上下箭头来放大。您还可以按 Ctrl 键并使用鼠标滚轮进行缩放。您应该会看到类似图 2 的内容。
这个球体只有白色,上面有一些灯光。它需要一个足球纹理。我的第一次尝试是使用六边形网格,如图 3 所示。
要将纹理应用于球体,请选择它,并在属性窗口中将 .png
文件分配给 Texture1
属性。尽管这看起来是个好主意,但结果却不尽如人意,如图 4 所示。
由于纹理点在球体上的投影,六边形失真了。您需要一个失真的纹理。
当您应用此纹理时,球体开始看起来更像一个足球。您只需调整一些属性使其更真实。为此,选择球并在属性窗口中编辑 Phong 效果。Phong 光照模型包括定向光照和环境光照,并模拟对象的反射特性。这是一个 Visual Studio 附带的着色器,您可以从工具箱中拖放。如果您想了解更多关于着色器以及如何使用 Visual Studio 着色器设计器设计它们的信息,请点击“更多信息”中的链接。将 MaterialSpecular 下的 Red
、Green
和 Blue
属性设置为 0.2,并将 MaterialSpecularPower 设置为 16。现在您有了一个更好看的足球(图 6)。
如果您不想在 Visual Studio 中设计自己的模型,您可以从网上获取预制模型。Visual Studio 接受 FBX、DAE 和 OBJ 格式的任何模型:您只需将它们添加到解决方案中的资产中。例如,您可以使用如图 7 所示的 .obj
文件(从 http://www.turbosquid.com 下载的免费模型)。
动画模型
模型就位后,是时候对其进行动画处理了。不过在此之前,我想移除不再需要的茶壶。在 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 * pi) 弧度的角度旋转。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();
ID3D11RenderTargetView *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::ToggleHitEffect(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, &meshTransform, &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 const& timer)
{
// Rotate scene.
m_rotation = static_cast<float>(timer.GetTotalSeconds()) * 0.5f;
const float maxHeight = 7.0f;
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 m/s2),s0 是 0(球从地面开始)。所以,方程变为
s = v0t -5t2
v = v0 -10t
您希望在 1 秒内达到最大高度。在最大高度时,速度为 0。因此,第二个方程允许您找到初始速度
0 = v0 – 10 * 1 => v0 = 10 m/s
这给出了球的平移
s = 10t – 5t2
您修改 Update
方法以使用此方程
void Game::Update(DX::StepTimer const& 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 文件夹中,右键单击以添加一个新的三维 (3D) 场景,并将其命名为 field.fbx
。从工具箱中添加一个平面并选择它,将其比例 X
更改为 107,Z
更改为 60。将其 Texture1
属性设置为足球场图像。您可以使用缩放工具(或按 Z 键)缩小。
然后,您必须在 Game.cpp
的 CreateDeviceDependentResources
中加载网格
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());
}
}
通过此更改,变换仅应用于球。场地在没有变换的情况下渲染。如果您现在运行代码,您会看到球在场地上弹跳,但它从底部“进入”场地。通过将场地沿 y 轴平移 −0.5 来修复此错误。选择场地并将其 y 轴上的平移属性更改为 −0.5。现在,当您运行应用程序时,您可以看到球在场地上弹跳,如图 8 所示。
设置摄像机和球的位置
球位于场地中心,但您不希望它在那里。对于这款游戏,球必须位于罚球点。如果您查看图 9 中的场景编辑器,您可以看到要做到这一点,您必须通过更改 Game.cpp
中 Render
方法中球的平移来沿 x 轴平移球
rotation *= XMMatrixTranslation(63.0, m_translation, 0);
球沿 x 轴平移 63 个单位,将其放置在罚球点。
通过此更改,您将不再看到球,因为相机位于另一个方向——在场地中间,看向中心。您需要重新定位相机,使其指向球门线,这在 Game.cpp
的 CreateWindowSizeDependentResources
中完成
m_graphics.GetCamera().SetViewport((UINT) outputSize.Width, (UINT) outputSize.Height);
m_graphics.GetCamera().SetPosition(XMFLOAT3(25.0f, 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);
相机的位置在中心点和罚球点之间,看向球门线。新视图类似于图 10。
现在,您需要添加球门。
添加球门柱
要将球门添加到场地中,您需要一个带有球门的新 3D 场景。您可以自己设计,也可以获取一个现成的模型。模型就位后,您必须将其添加到 Assets 文件夹中,以便可以对其进行编译和使用。
模型必须在 Game.cpp
的 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
);
});
加载后,在 Game.cpp
的 Render
方法中定位并绘制它
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 度旋转,以及一个沿 x 轴 85.5 个单位和沿 y 轴 −0.5 个单位的平移,这是由于您对场地进行的位移。这样,球门面向场地,位于球门线上,如图 11 所示。请注意,变换的顺序很重要:如果您在平移后应用旋转,球门将以完全不同的位置渲染,并且您将看不到任何东西。
射门
所有元素都已就位,但球仍在跳动。是时候踢球了。为此,您必须再次磨练您的物理技能。踢球的样子如图 12 所示。
球以初始速度 v0 踢出,角度为 α(如果您不记得物理课,只需玩一玩《愤怒的小鸟》即可看到它的实际效果)。球的运动可以分解为两种不同的运动:水平运动是匀速运动(我承认既没有空气摩擦也没有风力影响),垂直运动与之前使用的相同。水平运动方程是
sX = s0 + v0*cos(α)*t
. . . 垂直运动是
sY = s0 + v0*sin(α)*t – ½*g*t2
现在您有两个平移:一个沿 x 轴,另一个沿 y 轴。考虑到踢球角度为 45 度,cos(α) = sin(α) = sqrt(2)/2,因此 v0*cos(α) = v0*sin(α)*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(b2 − 4*a*c))/2*a ≥ (−11.5 ± sqrt(11.52 – 4 * −5 * 0)/2 * −5 ≥ 0 或 23/10 ≥ 2.3s
有了这些方程,您可以替换球的平移。首先,在 Game.h
中,创建变量来存储三个轴上的平移
float m_translationX, m_translationY, m_translationZ;
然后,在 Game.cpp
的 Update
方法中,添加方程
void Game::Update(DX::StepTimer const& 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 轴平移来实现这一点。
图 13 显示了罚球点到球门的距离为 22.5,球门柱之间的距离为 14。这使得 α = atan(7/22.5),即 17 度。您可以计算沿 z 轴的平移,但为了简单起见:球必须在到达球门柱的同时到达球门线。这意味着当球沿 x 轴移动 1 个单位时,它必须沿 z 轴移动 7/22.5 个单位。因此,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 const& 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);
….
您应该会看到类似图 14 的内容。
添加守门员
球的移动和球门都已就位,现在您需要添加一名守门员来接球。守门员将是一个变形的立方体。在 Assets 文件夹中,添加一个新项——一个新的 3D 场景——并将其命名为 goalkeeper.fbx
。
从工具箱中添加一个立方体并选择它。将其比例设置为 x 轴 0.3,y 轴 1.9,z 轴 1。将其 MaterialAmbient
属性的 Red
设置为 1,Blue
和 Green
属性设置为 0,使其变为红色。将 MaterialSpecular
中的 Red
属性设置为 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
);
});
下一步是将守门员定位并渲染在球门中心。您在 Game.cpp
的 Render
方法中执行此操作
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);
}
}
有了这段代码,守门员被放置在球门中心,如图 15 所示(请注意,截图的摄像机位置不同)。
现在,您需要让守门员左右移动来接球。用户将使用左右箭头键来改变守门员的移动。
守门员的移动受限于球门柱,它们位于 z 轴的 +7 和 −7 单位处。守门员在两个方向上都有 1 个单位,因此他可以在任一侧移动 6 个单位。
按键在 XAML 页面 (DirectXPage.xaml
) 中被截获,并将重定向到 Game
类。您在 DirectXPage.xaml
中添加一个 KeyDown
事件处理程序
<Page
x:Class="StarterKit.DirectXPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:StarterKit"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" KeyDown="OnKeyDown">
DirectXPage.xaml.cpp
中的事件处理程序是
void DirectXPage::OnKeyDown(Platform::Object^ sender, Windows::UI::Xaml::Input::KeyRoutedEventArgs^ e)
{
m_main->OnKeyDown(e->Key);
}
m_main
是 StarterKitMain
类的实例,它渲染游戏和 FPS 场景。您必须在 StarterKitMain.h
中声明一个公共方法
class StarterKitMain : public DX::IDeviceNotify
{
public:
StarterKitMain(const std::shared_ptr<DX::DeviceResources>& deviceResources);
~StarterKitMain();
// 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); }
….
此方法将键重定向到 Game
类中的 OnKeyDown
方法。现在,您必须在 Game.h
中声明 OnKeyDown
方法
class Game
{
public:
Game(const std::shared_ptr<DX::DeviceResources>& deviceResources);
void CreateDeviceDependentResources();
void CreateWindowSizeDependentResources();
void ReleaseDeviceDependentResources();
void Update(DX::StepTimer const& timer);
void Render();
void OnKeyDown(Windows::System::VirtualKey key);
….
此方法处理按下的键并用箭头移动守门员。在创建方法之前,您必须在 Game.h
中声明一个私有字段来存储守门员的位置
class Game
{
// snip
private:
// snip
float m_goalkeeperPosition;
守门员位置最初为 0,当用户按下箭头键时会递增或递减。如果位置大于 6 或小于 −6,守门员的位置将不会改变。您在 Game.cpp
的 OnKeyDown
方法中执行此操作
void Game::OnKeyDown(Windows::System::VirtualKey key)
{
const float MaxGoalkeeperPosition = 6.0;
const float MinGoalkeeperPosition = -6.0;
if (key == Windows::System::VirtualKey::Right)
m_goalkeeperPosition = m_goalkeeperPosition >= MaxGoalkeeperPosition ?
m_goalkeeperPosition : m_goalkeeperPosition + 0.1f;
else if (key == Windows::System::VirtualKey::Left)
m_goalkeeperPosition = m_goalkeeperPosition <= MinGoalkeeperPosition ?
m_goalkeeperPosition : m_goalkeeperPosition - 0.1f;
}
新的守门员位置在 Game.cpp
的 Render
方法中使用,守门员的变换在此处计算
auto goalkeeperTransform = XMMatrixTranslation(85.65f, 1.40f, m_goalkeeperPosition);
有了这些更改,您可以运行游戏,并看到当您按下箭头键时,守门员会左右移动(图 16)。
到目前为止,球一直在移动,但这并不是您想要的。球应该在被踢出后才移动,并在到达球门时停止。同样,守门员在球被踢出之前不应该移动。
您必须在 Game.h
中声明一个私有字段 m_isAnimating
,以便游戏知道球何时正在移动
class Game
{
public:
// snip
private:
// snip
bool m_isAnimating;
此变量在 Game.cpp
的 Update
和 Render
方法中使用,因此球仅在 m_isAnimating
为 true 时才移动
void Game::Update(DX::StepTimer const& timer)
{
if (m_isAnimating)
{
m_rotation = static_cast<float>(timer.GetTotalSeconds()) * 0.5f;
auto totalTime = (float) fmod(timer.GetTotalSeconds(), 2.3f);
m_translationX = 63.0f + 11.5f * totalTime;
m_translationY = 11.5f * totalTime - 5.0f * totalTime*totalTime;
m_translationZ = 3.0f * totalTime;
}
}
void Game::Render()
{
// snip
XMMATRIX modelTransform;
if (m_isAnimating)
{
modelTransform = XMMatrixRotationY(m_rotation);
modelTransform *= XMMatrixTranslation(m_translationX,
m_translationY, m_translationZ);
}
else
modelTransform = XMMatrixTranslation(63.0f, 0.0f, 0.0f);
….
变量 modelTransform
从循环移动到顶部。箭头键只应在 OnKeyDown
方法中当 m_isAnimating
为 true 时处理
void Game::OnKeyDown(Windows::System::VirtualKey key)
{
const float MaxGoalkeeperPosition = 6.0f;
if (m_isAnimating)
{
auto goalKeeperVelocity = key == Windows::System::VirtualKey::Right ?
0.1f : -0.1f;
m_goalkeeperPosition = fabs(m_goalkeeperPosition) >= MaxGoalkeeperPosition ?
m_goalkeeperPosition :
m_goalkeeperPosition + goalKeeperVelocity;
}
}
下一步是踢球。这发生在用户按下空格键时。在 Game.h
中声明一个新的私有字段 m_isKick
class Game
{
public:
// snip
private:
// snip
bool m_isKick;
在 Game.cpp
的 OnKeyDown
方法中将此字段设置为 true
void Game::OnKeyDown(Windows::System::VirtualKey key)
{
const float MaxGoalkeeperPosition = 6.0f;
if (m_isAnimating)
{
auto goalKeeperVelocity = key == Windows::System::VirtualKey::Right ?
0.1f : -0.1f;
m_goalkeeperPosition = fabs(m_goalkeeperPosition) >= MaxGoalkeeperPosition ?
m_goalkeeperPosition :
m_goalkeeperPosition + goalKeeperVelocity;
}
else if (key == Windows::System::VirtualKey::Space)
m_isKick = true;
}
当 m_isKick
为 true 时,动画在 Update
方法中开始
void Game::Update(DX::StepTimer const& timer)
{
if (m_isKick)
{
m_startTime = static_cast<float>(timer.GetTotalSeconds());
m_isAnimating = true;
m_isKick = false;
}
if (m_isAnimating)
{
auto totalTime = static_cast<float>(timer.GetTotalSeconds()) - m_startTime;
m_rotation = totalTime * 0.5f;
m_translationX = 63.0f + 11.5f * totalTime;
m_translationY = 11.5f * totalTime - 5.0f * totalTime*totalTime;
m_translationZ = 3.0f * totalTime;
if (totalTime > 2.3f)
ResetGame();
}
}
踢球的初始时间存储在变量 m_startTime
中(在 Game.h
中声明为私有字段),该变量用于计算踢球时间。如果超过 2.3 秒,游戏将重置(球应该已经到达球门)。您将 ResetGame
方法声明为 Game.h
中的私有方法
void Game::ResetGame()
{
m_isAnimating = false;
m_goalkeeperPosition = 0;
}
此方法将 m_isAnimating
设置为 false 并重置守门员的位置。球不需要重新定位,因为如果 m_isAnimating
为 false,它将绘制在罚球点。您需要进行的另一个更改是踢球角度。此代码将踢球固定在右侧门柱附近
m_translationZ = 3.0f * totalTime;
您必须更改它,使踢球有些随机,用户不知道球会去哪里。您必须在 Game.h
中声明一个私有字段 m_ballAngle
,并在球在 Update
方法中被踢出时初始化它
void Game::Update(DX::StepTimer const& timer)
{
if (m_isKick)
{
m_startTime = static_cast<float>(timer.GetTotalSeconds());
m_isAnimating = true;
m_isKick = false;
m_ballAngle = (static_cast <float> (rand()) /
static_cast <float> (RAND_MAX) -0.5f) * 6.0f;
}
…
Rand()/RAND_MAX
结果是一个介于 0 和 1 之间的数字。从结果中减去 0.5,使数字介于 −0.5 和 0.5 之间,然后乘以 6,最终角度介于 −3 和 3 之间。为了使每次游戏使用不同的序列,您必须在 CreateDeviceDependentResources
方法中通过调用 srand
来初始化生成器
void Game::CreateDeviceDependentResources()
{
srand(static_cast <unsigned int> (time(0)));
…
要调用时间函数,您必须包含 ctime
。您在 Update
方法中使用 m_ballAngle
来使用球的新角度
m_translationZ = m_ballAngle * totalTime;
大部分代码现在已就位,但您必须知道守门员是否接住了球或者用户是否进球了。使用一个简单的方法来找出答案:当球到达球门线时,您检查球的矩形是否与守门员的矩形相交。如果您愿意,您可以使用更复杂的方法来确定进球,但对于我们的需求来说,这已经足够了。所有计算都在 Update
方法中进行
void Game::Update(DX::StepTimer const& timer)
{
if (m_isKick)
{
m_startTime = static_cast<float>(timer.GetTotalSeconds());
m_isAnimating = true;
m_isKick = false;
m_isGoal = m_isCaught = false;
m_ballAngle = (static_cast <float> (rand()) /
static_cast <float> (RAND_MAX) -0.5f) * 6.0f;
}
if (m_isAnimating)
{
auto totalTime = static_cast<float>(timer.GetTotalSeconds()) - m_startTime;
m_rotation = totalTime * 0.5f;
if (!m_isCaught)
{
// ball traveling
m_translationX = 63.0f + 11.5f * totalTime;
m_translationY = 11.5f * totalTime - 5.0f * totalTime*totalTime;
m_translationZ = m_ballAngle * totalTime;
}
else
{
// if ball is caught, position it in the center of the goalkeeper
m_translationX = 83.35f;
m_translationY = 1.8f;
m_translationZ = m_goalkeeperPosition;
}
if (!m_isGoal && !m_isCaught && m_translationX >= 85.5f)
{
// ball passed the goal line - goal or caught
auto ballMin = m_translationZ - 0.5f + 7.0f;
auto ballMax = m_translationZ + 0.5f + 7.0f;
auto goalkeeperMin = m_goalkeeperPosition - 1.0f + 7.0f;
auto goalkeeperMax = m_goalkeeperPosition + 1.0f + 7.0f;
m_isGoal = (goalkeeperMax < ballMin || goalkeeperMin > ballMax);
m_isCaught = !m_isGoal;
}
if (totalTime > 2.3f)
ResetGame();
}
}
在 Game.h
中声明两个私有字段:m_isGoal
和 m_IsCaught
。这些字段告诉您用户是否进球或守门员是否接住了球。如果两者都为 false,则球仍在移动。当球到达守门员时,程序会计算球和守门员的边界,并确定球的边界是否与守门员的边界重叠。如果您查看代码,您会发现我为每个边界添加了 7.0 f。我这样做是因为边界可以是正数或负数,这会使重叠计算复杂化。通过添加 7.0 f,您可以确保所有数字都是正数,从而简化计算。如果球被接住,球的位置将设置为守门员的中心。当踢球时,m_isGoal
和 m_IsCaught
会被重置。现在,是时候向游戏中添加记分板了。
添加记分
在 DirectX 游戏中,您可以使用 Direct2D 渲染分数,但当您开发 Windows 8 游戏时,您还有另一种方法:使用 XAML。您可以在游戏中重叠 XAML 元素,并在 XAML 元素和游戏逻辑之间建立一个桥梁。这是一种更简单的方式来显示信息并与用户互动,因为您无需处理元素位置、渲染和更新循环。
Starter Kit 附带了一个 XAML 记分板(用于记录元素上的点击)。您只需修改它以保持进球分数。第一步是更改 DirectXPage.xaml
以更改记分板
<SwapChainPanel x:Name="swapChainPanel" Tapped="OnTapped" >
<Border VerticalAlignment="Top" HorizontalAlignment="Center" Padding="10" Background="Black"
Opacity="0.7">
<StackPanel Orientation="Horizontal" >
<TextBlock x:Name="ScoreUser" Text="0" Style="{StaticResource HudCounter}"/>
<TextBlock Text="x" Style="{StaticResource HudCounter}"/>
<TextBlock x:Name="ScoreMachine" Text="0" Style="{StaticResource HudCounter}"/>
</StackPanel>
</Border>
</SwapChainPanel>
既然在这里,您可以删除底部应用栏,因为它不会在此游戏中使用。您已经删除了分数中的所有点击计数器,因此您只需删除 DirectXPage.xaml.cpp
中 OnTapped
处理程序中提及它们的代码
void DirectXPage::OnTapped(Object^ sender, TappedRoutedEventArgs^ e)
{
}
您还可以从 cpp
和 h
页面中删除 OnPreviousColorPressed
、OnNextColorPressed
和 ChangeObjectColor
,因为这些在您删除的应用栏按钮中使用过。
要更新游戏的分数,Game
类和 XAML 页面之间必须有某种通信方式。游戏分数在 Game
类中更新,而分数在 XAML 页面中显示。一种方法是在 Game
类中创建事件,但这种方法存在问题。如果您向 Game
类添加事件,您会收到编译错误:“WinRT 事件声明必须出现在 WinRT 类中。”这是因为 Game
不是 WinRT
(ref
) 类。要成为 WinRT
类,您必须将事件定义为公共 ref
类并将其密封
public ref class Game sealed
您可以更改该类来做到这一点,但让我们走另一条路:创建一个新类——在这种情况下,一个 WinRT
类——并使用它在 Game
类和 XAML 页面之间进行通信。创建一个新类并将其命名为 ViewModel
#pragma once
ref class ViewModel sealed
{
public:
ViewModel();
};
在 ViewModel.h
中,添加更新分数所需的事件和属性
#pragma once
namespace StarterKit
{
ref class ViewModel sealed
{
private:
int m_scoreUser;
int m_scoreMachine;
public:
ViewModel();
event Windows::Foundation::TypedEventHandler<Object^, Platform::String^>^ PropertyChanged;
property int ScoreUser
{
int get()
{
return m_scoreUser;
}
void set(int value)
{
if (m_scoreUser != value)
{
m_scoreUser = value;
PropertyChanged(this, L"ScoreUser");
}
}
};
property int ScoreMachine
{
int get()
{
return m_scoreMachine;
}
void set(int value)
{
if (m_scoreMachine != value)
{
m_scoreMachine = value;
PropertyChanged(this, L"ScoreMachine");
}
}
};
};
}
在 Game.h
中声明一个 ViewModel
类型的私有字段(您必须在 Game.h
中包含 ViewModel.h
)。您还应该为该字段声明一个公共 getter
class Game
{
public:
// snip
StarterKit::ViewModel^ GetViewModel();
private:
StarterKit::ViewModel^ m_viewModel;
此字段在 Game.cpp
的构造函数中初始化
Game::Game(const std::shared_ptr<DX::DeviceResources>& deviceResources) :
m_loadingComplete(false),
m_deviceResources(deviceResources)
{
CreateDeviceDependentResources();
CreateWindowSizeDependentResources();
m_viewModel = ref new ViewModel();
}
getter 主体是
StarterKit::ViewModel^ Game::GetViewModel()
{
return m_viewModel;
}
当当前的踢球结束时,变量在 Game.cpp
的 ResetGame
中更新
void Game::ResetGame()
{
if (m_isCaught)
m_viewModel->ScoreUser++;
if (m_isGoal)
m_viewModel->ScoreMachine++;
m_isAnimating = false;
m_goalkeeperPosition = 0;
}
当这两个属性中的任何一个发生更改时,都会引发 PropertyChanged
事件,该事件可以在 XAML 页面中处理。这里仍然存在一个间接:XAML 页面无法直接访问 Game
(一个非 ref
类),而是调用 StarterKitMain
类。您必须在 StarterKitMain.h
中为 ViewModel
创建一个 getter
class StarterKitMain : public DX::IDeviceNotify
{
public:
// snip
StarterKit::ViewModel^ GetViewModel() { return m_sceneRenderer->GetViewModel(); }
有了这个基础设施,您就可以在 DirectXPage.xaml.cpp
的构造函数中处理 ViewModel
的 PropertyChanged
事件了
DirectXPage::DirectXPage():
m_windowVisible(true),
m_hitCountCube(0),
m_hitCountCylinder(0),
m_hitCountCone(0),
m_hitCountSphere(0),
m_hitCountTeapot(0),
m_colorIndex(0)
{
// snip
m_main = std::unique_ptr<StarterKitMain>(new StarterKitMain(m_deviceResources));
m_main->GetViewModel()->PropertyChanged += ref new
TypedEventHandler<Object^, String^>(this, &DirectXPage::OnPropertyChanged);
m_main->StartRenderLoop();
}
处理程序更新分数(您还必须在 DirectXPage.xaml.cpp.h
中声明它)
void StarterKit::DirectXPage::OnPropertyChanged(Platform::Object ^sender, Platform::String ^propertyName)
{
if (propertyName == "ScoreUser")
{
auto scoreUser = m_main->GetViewModel()->ScoreUser;
Dispatcher->RunAsync(CoreDispatcherPriority::Normal, ref new DispatchedHandler([this, scoreUser]()
{
ScoreUser->Text = scoreUser.ToString();
}));
}
if (propertyName == "ScoreMachine")
{
auto scoreMachine= m_main->GetViewModel()->ScoreMachine;
Dispatcher->RunAsync(CoreDispatcherPriority::Normal, ref new DispatchedHandler([this, scoreMachine]()
{
ScoreMachine->Text = scoreMachine.ToString();
}));
}
}
现在,每当用户进球或守门员接球时,分数都会更新(图 17)。
在游戏中使用触控和传感器
游戏运行良好,但您仍然可以为其增添趣味。新的 Ultrabook™ 设备具有触控输入和传感器,您可以使用它们来增强游戏。用户无需使用键盘踢球和移动守门员,可以通过点击屏幕来踢球,并通过向右或向左倾斜屏幕来移动守门员。
要通过点击屏幕来踢球,请使用 DirectXPage.cpp
中的 OnTapped
事件
void DirectXPage::OnTapped(Object^ sender, TappedRoutedEventArgs^ e)
{
m_main->OnKeyDown(VirtualKey::Space);
}
代码调用 OnKeyDown
方法,将空格键作为参数传递——与用户按下空格键的代码相同。如果您愿意,您可以增强此代码以获取点击的位置,并且仅当点击在球上时才踢球。我将此作为您的家庭作业。作为起点,Starter Kit 包含用于检测用户是否点击场景中对象的代码。
下一步是当用户倾斜屏幕时移动守门员。为此,您使用倾斜仪,它检测屏幕的所有运动。该传感器返回三个读数:俯仰、滚动和偏航,分别对应于绕 x、y 和 z 轴的旋转。对于这款游戏,您只需要滚动读数。
要使用传感器,您必须获取它的实例,您可以通过 GetDefault
方法来完成。然后,您设置它的报告间隔,就像 Game.cpp
中 void Game::CreateDeviceDependentResources
中的这段代码一样
void Game::CreateDeviceDependentResources()
{
m_inclinometer = Windows::Devices::Sensors::Inclinometer::GetDefault();
if (m_inclinometer != nullptr)
{
// Establish the report interval for all scenarios
uint32 minReportInterval = m_inclinometer->MinimumReportInterval;
uint32 reportInterval = minReportInterval > 16 ? minReportInterval : 16;
m_inclinometer->ReportInterval = reportInterval;
}
...
m_inclinometer
是在 Game.h
中声明的私有字段。在 Update
方法中,重新定位守门员
void Game::Update(DX::StepTimer const& timer)
{
// snip
SetGoalkeeperPosition();
if (totalTime > 2.3f)
ResetGame();
}
}
SetGoalkeeperPosition
根据倾斜仪读数重新定位守门员
void StarterKit::Game::SetGoalkeeperPosition()
{
if (m_isAnimating && m_inclinometer != nullptr)
{
Windows::Devices::Sensors::InclinometerReading^ reading =
m_inclinometer->GetCurrentReading();
auto goalkeeperVelocity = reading->RollDegrees / 100.0f;
if (goalkeeperVelocity > 0.3f)
goalkeeperVelocity = 0.3f;
if (goalkeeperVelocity < -0.3f)
goalkeeperVelocity = -0.3f;
m_goalkeeperPosition = fabs(m_goalkeeperPosition) >= 6.0f ?
m_goalkeeperPosition : m_goalkeeperPosition + goalkeeperVelocity;
}
}
有了这个改变,您可以通过倾斜屏幕来移动守门员。您现在有了一个完成的游戏。
性能测量
游戏在您的开发系统上运行良好后,您现在应该在性能较低的移动设备上尝试一下。在一台拥有顶级图形处理器和 60 FPS 的强大工作站上进行开发是一回事。而在一台搭载 Intel® Atom™ 处理器和内置显卡的设备上运行您的游戏则完全不同。
您的游戏应该在这两台机器上都表现良好。为了衡量性能,您可以使用 Visual Studio 中包含的工具或 Intel® Graphics Performance Analyzers (Intel® GPA),这是一套图形分析器,可以检测瓶颈并提高您的游戏性能。Intel GPA 提供您的游戏性能的图形分析,并可以帮助您使其运行更快更流畅。
结论
最后,您已经完成了旅程。您从一个跳舞的茶壶开始,最终完成了一个带有键盘和传感器输入的 DirectX 游戏。随着语言越来越相似,C++/CX 对 C# 开发人员来说并不难使用。
主要的困难在于掌握 3D 模型,使它们移动,并以熟悉的方式定位它们。为此,您不得不使用一些物理、几何、三角学和数学。
底线是,开发游戏并非不可能的任务。只要有耐心和正确的工具,您就可以创建出性能卓越的优秀游戏。
特别感谢
我要感谢 Roberto Sonnino 的写作技巧和对本文的技术审阅。
图片来源
- 球纹理:http://forums.creativecow.net/thread/2/970549
- 足球场:http://www.fullhdwpp.com/wp-content/uploads/Soccer-Field_www.FullHDWpp.com_.jpg
- 球网:http://www.moddingway.com/file/14374.html
- 球门:http://www.the3dstudio.com/product_details.aspx?id_product=4630
更多信息
- “Herb Sutter:(不是你父亲的)C++”:http://channel9.msdn.com/Events/Lang-NEXT/Lang-NEXT-2012/-Not-Your-Father-s-C-
- Visual Studio Windows 8 3D 入门套件:http://code.msdn.microsoft.com/windowsapps/Visual-Studio-3D-Starter-54ec8d19
- “使用着色器”:http://msdn.microsoft.com/en-us/library/hh873117.aspx
- Intel GPA:https://software.intel.com/en-us/vcsource/tools/intel-gpa
英特尔®开发者专区提供用于跨平台应用开发的工具和操作指南、平台和技术信息、代码示例以及同行专业知识,帮助开发人员创新和成功。加入我们的物联网、Android*、英特尔®实感™技术和Windows*社区,下载工具,访问开发套件,与志同道合的开发人员分享想法,并参与黑客马拉松、竞赛、路演和本地活动。
Intel、Intel 标识、Intel Atom 和 Ultrabook 是英特尔公司在美国和/或其他国家的商标。
*其他名称和品牌可能被声明为他人的财产。
版权所有 © 2014。英特尔公司。保留所有权利。