八、虚拟摄像机
对于摄影师和电影制作人来说,相机是一个显而易见的工具。它们使艺术家能够通过控制光线捕捉的位置和设置来捕捉他们周围的生活世界,从而创造出他们所选择场景的表现形式。电子游戏也不例外。
到目前为止,我们的场景基本上保持静态,但这不会成为一个特别有趣的游戏。你们中的大多数人以前都玩过 3D 游戏,我相信你会同意在世界中移动的能力是让游戏引人注目的一个关键方面。如果不定义摄像机,这是不可能的。
在摄影和电影制作领域,相机是一个相对容易理解的概念。要捕捉不同的视角,你只需将相机移动到某个位置,它就能捕捉到你想要描绘的场景。3D 图形中的相机并不那么简单。在这一章中,我们将看看我们如何定义一个相机游戏对象,以及我们如何使用该对象来通过我们的机器人跑垒员关卡。
让我们开始看看游戏摄像机背后的理论。
模型和视图矩阵
正如我们已经看到的,我们可以通过改变变换矩阵,将该矩阵提供给顶点着色器,并将顶点乘以该矩阵,在 3D 世界中移动对象。变换矩阵包含缩放、旋转和平移元素,这些元素会影响游戏对象的大小、方向和位置。
图 8-1 显示了比例矩阵对游戏对象的影响。
图 8-1 。缩放立方体
变换的旋转元素围绕对象的原点旋转顶点。如图 8-2 中的所示。
图 8-2 。旋转对游戏对象的影响
变换矩阵的平移组件负责在 3D 空间中移动对象。图 8-3 显示了应用于游戏对象位置的偏移。
图 8-3 。翻译游戏对象
我们创建的清单 6-20 中的负责存储每个对象的变换信息。清单 6-28 中的顶点着色器代码用于在渲染管道中处理顶点时将变换矩阵应用于顶点。
我们的相机对象将是一个游戏对象,就像任何其他对象一样。它将通过更新包含在其TransformComponent
中的Transform
在场景中移动,但相机本身不会被渲染;相反,相机的位置和方向将用于操纵场景中的其他对象。
在第五章中,我们介绍了规范视图体的概念。我们讨论了顶点着色器负责将顶点转换到相对于该体积的位置。到目前为止,我们缺乏一个相机模型,这意味着我们必须在世界空间中移动对象,以使它们在这个体积内正确显示。现在将由相机负责修改顶点以适应这个体积。
当我们用相机拍照时,我们知道我们可以移动相机来获得不同的场景视图。我们可以在我们的游戏世界中,通过使用它的TransformComponent
来操控相机,达到同样的效果;然而,这不会帮助我们操纵顶点着色器中其他对象的顶点。我们必须把渲染管道中的摄像机想象成一个永远不会移动的物体。所有其他对象必须相对于相机对象四处移动。为了帮助解决这个问题,想象一下你正在用相机拍全家福。如果你决定你真的喜欢从稍微偏右的位置拍摄,你可以向右移动一步。在 3D 游戏中,这是不可能的,因为渲染管道中的相机基本上是固定的,所以我们必须想象我们必须移动世界中的对象。在我们的视觉化中,你可以想象,为了获得相同的场景视图,你可以要求照片中的人向左走一步,而不是向右走一步。你会拿着相机保持不动,但你会得到相同的角度来拍摄你的对象,就好像你走到了左边。
我们通过应用变换的逆变换在 3D 数学中实现这一点。转换中最容易可视化的部分是翻译组件。如果我们的摄影机对象的平移包含沿 z 轴 10 个单位的正偏移,我们将通过对顶点位置应用–10 的偏移来操纵场景中的每个其他对象。我们已经在清单 6-23 的中写了一个获得逆变换的方法。
快速回顾一下,我们的相机对象将是一个带有一个TransformComponent
的GameObject
,就像其他的一样。我们将对摄像机应用正向变换,使其在游戏世界中移动。我们将把摄像机的Transform
的逆矩阵提供给Renderer
作为视图矩阵。
这涵盖了片段着色器中顶点变换的两个基础。模型矩阵将对象的顶点从其局部空间操纵到世界空间。视图矩阵然后将顶点从世界空间操纵到相机或视图空间。
顶点着色器中顶点变换过程的最后一部分是投影矩阵。
投影矩阵
为了理解为什么投影矩阵如此重要,尤其是在移动开发中,我们必须再次考虑规范视图体。该体积是顶点经过顶点着色器处理后所在的空间。规范视图体在 OpenGL ES 2.0 中是边维数相等的立方体(在其他 API 中可能有不同的属性;例如,DirectX 使用在 z 轴上范围仅从 0 到 1 的立方体)。如果你看到一系列 Android 设备并排放置,很明显屏幕可能都是不同的尺寸。
设备本身的大小不是问题,因为我们的游戏将会适应更大的屏幕。问题来自于不同长宽比的屏幕。我们用宽度除以高度来计算长宽比。我现在的设备是 Galaxy Nexus,屏幕分辨率为 1280 * 720,长宽比为 1.778。最初的华硕变压器的屏幕分辨率为 1280 * 800,长宽比为 1.6。这些可能看起来没有明显的区别;但是,它们会直接影响我们的产出。如果我们没有校正设备的纵横比,我们将允许立方体视图体积被水平拉伸以适合设备的屏幕。正方形会变成长方形,而球体看起来不会是正圆。
这是投影矩阵解决的问题之一。
3D 图形中通常使用两种不同类型的投影矩阵。一个是正投影,一个是透视投影。正投影的关键特性是平行线在投影后保持平行。透视投影并不保持平行线,顾名思义,这可以让我们产生透视感。我们大多数人都知道消失点的概念。绘画中的这种效果是通过让平行线在远处的一个点上会聚来实现的,这和我们使用透视投影得到的效果是一样的。
图 8-4 显示了长方体正投影体和透视图体的平截头体,从俯视图上覆盖在标准视图体上。
图 8-4 。投影视图体积
如图 8-4 所示,透视图体由一个平截头体表示。平截头体的近平面以与远平面相同的方式映射到立方体上。由于远平面要宽得多,平截头体后面的对象在尺寸上被挤压以适合规范视图体积。物体离相机越远,这种挤压就是我们获得透视感的方式。这种挤压不会发生在正交投影中,无论对象离相机多近,它们看起来都是相同的大小。
我们将在我们的相机中使用投影的透视方法,我们将在下一节看看我们如何实现这一点。
定义相机对象
与我们目前所有的其他对象一样,我们的相机将是一个带有新组件类型CameraComponent
的GameObject
。
清单 8-1 中的类声明显示了CameraComponent
的接口。我们有常用的static int
和GetId
方法来添加对组件系统的支持。
清单 8-1。CameraComponent
类声明。相机组件. h
class CameraComponent
: public Component
, public EventHandler
{
private:
static const unsigned int s_id = 3;
float m_frustumParameters[Renderer::NUM_PARAMS];
public:
static unsigned int GetId() { return s_id; }
explicit CameraComponent(GameObject* pOwner);
virtual ∼CameraComponent();
virtual void Initialize() {}
void SetFrustum(
const float verticalFieldOfView,
const float aspectRatio,
const float near,
const float far);
virtual void HandleEvent(Event* pEvent);
};
然后我们会看到存储的截锥参数。这些参数定义了视图截锥边缘的边界。平截头体的顶部、底部、左侧、右侧、近侧和远侧平面都有一个参数。
我们还有两个公共方法,SetFrustum
和HandleEvent
。
我们来看看清单 8-2 中的SetFrustum
。
清单 8-2。 CameraComponent::SetFrustum
。相机组件. cpp
void CameraComponent::SetFrustum(
const float verticalFieldOfView,
const float aspectRatio,
const float near,
const float far)
{
float halfAngleRadians = 0.5f * verticalFieldOfView * (3.1415926536f / 180.0f);
m_frustumParameters[Renderer::TOP] = near * (float)tan(halfAngleRadians);
m_frustumParameters[Renderer::BOTTOM] = -m_frustumParameters[Renderer::TOP];
m_frustumParameters[Renderer::RIGHT] =
aspectRatio * m_frustumParameters[Renderer::TOP];
m_frustumParameters[Renderer::LEFT] = -m_frustumParameters[Renderer::RIGHT];
m_frustumParameters[Renderer::NEAR] = near;
m_frustumParameters[Renderer::FAR] = far;
}
SetFrustum
根据传入的参数计算锥台参数。我们将基于屏幕的长宽比和以度为单位的垂直视野来计算我们的平截头体。视野是一个角度,表示平截头体的宽度:视野越大,近平面和远平面的宽度差越大。
更大的视野会给我们一种在传统相机上缩小的效果;更窄的视野给我们一种放大物体的感觉。这种效果最常见的例子是第一人称射击游戏中的狙击瞄准镜。范围视图将具有更窄的视野,并且远处物体的比例在帧缓冲器中大大增加。
我们的平截头体被计算成两半,相反的一半只是它的对应部分被求反。因为我们是对半计算,所以我们把垂直视野乘以 0.5f .然后我们把verticalFieldOfView
乘以 3.1415926536 f/180.0 f;这会将过去的角度从度转换为弧度。结合起来,这给了我们字段halfAngleRadians
。
通过用弧度表示的半角的正切值乘以 near 值来计算平截头体的顶部。这是简单的三角学,其中 tan(x) =对立/相邻,在我们的例子中,近平面的距离是相邻的,所以对立=相邻* tan(x)。C++ 中的 tan 方法采用弧度作为角度,这也是该方法第一行转换成弧度的原因。人更习惯于和度打交道,这很正常,所以我们用度传递我们的角度,内部转换。
平截头体的底部就是顶部的反面。
我们的右参数是顶部参数乘以提供的纵横比;左只是右的反义词。这些线条显示了为什么我们使用垂直视场法来计算截锥的尺寸。通过提供垂直视野,我们锁定了所有设备上的平截头体的高度。不管我们的纵横比是 16:9、16:10 还是其他什么,这都是一致的。在我们的宽高比较宽的情况下,我们将能够在每一侧看到更多的场景,当屏幕较窄时,情况正好相反。垂直视野在我们的例子中起作用,因为我们正在开发一个横向的游戏;如果您正在开发一个纵向方向,您可能要考虑使用锁定的水平视野。
近平面和远平面的距离存储在函数参数中。
更新CameraComponent
包括向渲染器提供摄像机的当前状态,这样我们就可以正确地渲染场景中的物体(见清单 8-3 )。
清单 8-3。 CameraComponent::HandleEvent
。相机组件. cpp
void CameraComponent::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == POSTUPDATE_EVENT)
{
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
Renderer& renderer = Renderer::GetSingleton();
Matrix4 inverseCamera;
pTransformComponent->GetTransform().GetInverseMatrix(inverseCamera);
renderer.SetViewMatrix(inverseCamera);
renderer.SetFrustum(m_frustumParameters);
}
}
CameraComponent
对POSTUPDATE_EVENT
做出反应,以确保必要时UPDATE_EVENT
中的TransformComponent
已经更新。
我们获得一个指向我们所有者的TransformComponent
的指针,并向它请求其Transform
对象的逆。我们在本章前面讨论了为什么我们应该使用相机的Transform
的反转。
逆视图矩阵和当前视锥参数通过SetViewMatrix
和SetFrustum
方法提供给Renderer
。
现在我们有了一个CameraComponent
来添加到游戏对象并模拟一个虚拟摄像机,我们需要看看如何更新Renderer
来适应视图和投影矩阵。
更新渲染器
现在我们有了一个将Camera
表示为GameObject
的方法,我们需要为我们的Renderer
添加对视图和投影矩阵的支持。
在清单 8-4 的中,我们通过向Renderer
类添加以下更新来做到这一点。
清单 8-4。 给Renderer
增加视图和投影矩阵支持。Renderer.h
class Renderer
: public Task
, public Singleton<Renderer>
{
public:
enum FrustumParameters
{
TOP,
BOTTOM,
RIGHT,
LEFT,
NEAR,
FAR,
NUM_PARAMS
};
private:
android_app* m_pState;
EGLDisplay m_display;
EGLContext m_context;
EGLSurface m_surface;
Int m_width;
Int m_height;
Bool m_initialized;
typedef std::vector<Shader*> ShaderVector;
typedef ShaderVector::iterator ShaderVectorIterator;
typedef std::vector<Texture*> TextureVector;
typedef TextureVector::iterator TextureVectorIterator;
typedef std::vector<Renderable*> RenderableVector;
typedef RenderableVector::iterator RenderableVectorIterator;
RenderableVector m_renderables;
TextureVector m_textures;
ShaderVector m_shaders;
float m_frustumParameters[NUM_PARAMS];
Matrix4 m_viewMatrix;
Matrix4 m_projectionMatrix;
void Draw(Renderable* pRenderable);
public:
explicit Renderer(android_app* pState, const unsigned int priority);
virtual ∼Renderer();
void Init();
void Destroy();
void AddRenderable(Renderable* pRenderable);
void AddShader(Shader* pShader);
void RemoveShader(Shader* pShader);
void AddTexture(Texture* pTexture);
void RemoveTexture(Texture* pTexture);
// From Task
virtual bool Start();
virtual void OnSuspend();
virtual void Update();
virtual void OnResume();
virtual void Stop();
bool IsInitialized() { return m_initialized; }
void SetViewMatrix(const Matrix4&
viewMatrix)
{
m_viewMatrix = viewMatrix;
}
const Matrix4& GetViewMatrix() const { return m_viewMatrix; }
void SetFrustum(const float frustumParameters[]);
const Matrix4& GetProjectionMatrix() const { return m_projectionMatrix; }
int GetWidth() cons { return m_width; }
int GetHeight() const { return m_height; }
};
第一个添加是 enum,它添加了六个截锥参数的定义。还有一个存储参数的浮点数组和两个存储视图和投影矩阵的矩阵。
接下来是获取和设置新字段的访问器方法。
唯一不直接的方法是SetFrustum
法。CameraComponent::SetFrustum
方法采用垂直视野和纵横比来构建创建虚拟相机截锥边界所需的截锥参数。Renderer
的SetFrustum
方法将这些参数作为输入,并从中构建一个投影矩阵。我们来看看清单 8-5 中的这个方法。
清单 8-5。 Renderer::SetFrustum
,渲染器. cpp
void Renderer::SetFrustum(const float frustumParameters[])
{
for (unsigned int i=0; i<NUM_PARAMS; ++i)
{
m_frustumParameters[i] = frustumParameters[i];
}
m_projectionMatrix.m_m[0] =
(2.0f * m_frustumParameters[NEAR]) /
(m_frustumParameters[RIGHT] - m_frustumParameters[LEFT]);
m_projectionMatrix.m_m[1] = 0.0f;
m_projectionMatrix.m_m[2] = 0.0f;
m_projectionMatrix.m_m[3] = 0.0f;
m_projectionMatrix.m_m[4] = 0.0f;
m_projectionMatrix.m_m[5] =
(2.0f * m_frustumParameters[NEAR]) /
(m_frustumParameters[TOP] - m_frustumParameters[BOTTOM]);
m_projectionMatrix.m_m[6] = 0.0f;
m_projectionMatrix.m_m[7] = 0.0f;
m_projectionMatrix.m_m[8] =
-((m_frustumParameters[RIGHT] + m_frustumParameters[LEFT]) /
(m_frustumParameters[RIGHT] - m_frustumParameters[LEFT]));
m_projectionMatrix.m_m[9] =
-((m_frustumParameters[TOP] + m_frustumParameters[BOTTOM]) /
(m_frustumParameters[TOP] - m_frustumParameters[BOTTOM]));
m_projectionMatrix.m_m[10] =
(m_frustumParameters[FAR] + m_frustumParameters[NEAR]) /
(m_frustumParameters[FAR] - m_frustumParameters[NEAR]);
m_projectionMatrix.m_m[11] = 1.0f;
m_projectionMatrix.m_m[12] = 0.0f;
m_projectionMatrix.m_m[13] = 0.0f;
m_projectionMatrix.m_m[14] =
-(2.0f * m_frustumParameters[NEAR] * m_frustumParameters[FAR]) /
(m_frustumParameters[FAR] - m_frustumParameters[NEAR]);
m_projectionMatrix.m_m[15] = 0.0f;
}
透视投影矩阵是通过将缩放矩阵与修改要挤压的顶点的矩阵以及用于为渲染流水线中被称为透视分割的步骤准备顶点的矩阵相结合而创建的。
透视投影中涉及的数学可能会变得非常复杂,因此我们不会在此详述,因为我们需要知道的是,前面的代码将创建一个透视投影矩阵,该矩阵适用于将顶点转换为 Android 上 OpenGL ES 2.0 使用的规范视图体。
连接模型、视图和投影矩阵
我们将要依赖的矩阵变换的一个性质是连接。矩阵可以相乘,它们各自的变换将合并成一个矩阵。这些矩阵相乘的顺序非常重要。在我们的例子中,我们希望确保应用模型转换,然后是视图转换,最后是投影转换。我们通过从模型矩阵开始并乘以视图矩阵来实现这一点。然后,我们将得到的矩阵乘以投影矩阵。
这段代码可以在TransformShader::Setup
中找到,我们可以在的清单 8-6 中看到。
清单 8-6。 更新TransformShader::Setup
。TransformShader.cpp
void TransformShader::Setup(Renderable& renderable)
{
Geometry* pGeometry = renderable.GetGeometry();
if (pGeometry)
{
Shader::Setup(renderable);
Renderer&**renderer = Renderer::GetSingleton();**
**const Matrix4&****viewMatrix = renderer.GetViewMatrix();**
**const Matrix4&****projectionMatrix = renderer.GetProjectionMatrix();**
**Matrix4 modelViewMatrix;**
**renderable.GetTransform().GetMatrix().Multiply(viewMatrix, modelViewMatrix);**
**Matrix4 modelViewProjectionMatrix;**
**modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);**
**glUniformMatrix4fv(**
**m_transformUniformHandle,**
**1,**
**false,**
**modelViewProjectionMatrix.m_m);**
`glVertexAttribPointer(`
`m_positionAttributeHandle,`
`pGeometry->GetNumVertexPositionElements(),`
`GL_FLOAT,`
`GL_FALSE,`
`pGeometry->GetVertexStride(),`
`pGeometry->GetVertexBuffer());`
`glEnableVertexAttribArray(m_positionAttributeHandle);`
`Vector4& color = renderable.GetColor();`
`glUniform4f(`
`m_colorAttributeHandle,`
`color.m_x,`
`color.m_y,`
`color.m_z,`
`color.m_w);`
`}`
`}`
`清单 8-6 中更新的代码显示了我们如何将矩阵连接在一起。
Renderable
的Transform
为这个对象提供了模型矩阵。我们将这个模型矩阵乘以从Renderer
中获得的viewMatrix
。得到的矩阵modelViewMatrix
然后乘以projectionMatrix
,也是从Renderer
获得的。
最终的矩阵通过glUniformMatrix4fv
调用提供给 OpenGL(在清单 8-6 中粗体代码块的末尾)。
现在我们的框架已经更新到支持相机对象,我们应该添加代码来更新每一帧中相机的位置。
更新摄像机的变换
我们在第三章中展示的机器人跑垒员的设计要求我们的游戏从左到右自动更新玩家的位置。如果我们不与玩家同时更新摄像机,我们玩家的GameObject
就会移出屏幕,玩家就看不到动作了。
对于如何处理这种情况,有几种选择。我们可以选择以与玩家对象每帧完全相同的方式更新摄像机的位置,并依靠两个对象以相同的速度移动来保持玩家的GameObject
在屏幕上。
另一种选择是将相机“附加”到玩家的对象上,并让相机使用玩家位置的偏移来更新其每一帧的位置。这是我决定使用的方法。
为了实现这一点,我们将创建一个新的组件,BoundObjectComponent
,这个组件的声明如清单 8-7 所示。
清单 8-7。BoundObjectComponent
类声明。BoundObjectComponent.h
class BoundObjectComponent
: public Component
, public EventHandler
{
private:
static const unsigned int s_id = 4;
Transform m_offsetTransform;
const TransformComponent* m_pBoundObject;
public:
static unsigned int GetId() { return s_id; }
explicit BoundObjectComponent(GameObject* pOwner);
virtual ∼BoundObjectComponent();
virtual void Initialize() {}
Transform& GetTransform() { return m_offsetTransform; }
const Transform& GetTransform() const { return m_offsetTransform; }
void SetBoundObject(const TransformComponent* pComponent)
{
m_pBoundObject = pComponent;
}
const TransformComponent* GetBoundObject() const { return m_pBoundObject; }
virtual void HandleEvent(Event* pEvent);
};
BoundObjectComponent
的设置方式和我们其他的Component
职业和EventHandlers
一样。重要字段是m_offsetTransform
和m_pBoundObject
。
m_offsetTransform
将存储从父对象偏移的Transform
信息。m_pBoundObject
将存储一个指向TransformComponent
的指针,用于我们希望通过关卡跟踪的对象。
这个Component
完成的所有努力都包含在HandleEvent
方法中。我们来看看清单 8-8 中的方法。
清单 8-8。 BoundObjectComponent::HandleEvent
。BoundObjectComponent.cpp
void BoundObjectComponent::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == UPDATE_EVENT && m_pBoundObject)
{
TransformComponent* pTransformComponent =
component_cast<TransformComponent>(GetOwner());
assert(pTransformComponent);
Transform& ourTransform = pTransformComponent->GetTransform();
const Transform& boundTransform = m_pBoundObject->GetTransform();
Vector3 translation = m_offsetTransform.GetTranslation();
translation.Add(boundTransform.GetTranslation());
ourTransform.SetTranslation(translation);
ourTransform.UpdateMatrix();
}
}
如你所见,HandleEvent
附属于UPDATE_EVENT
。当这个对象被更新时,我们得到我们的所有者对象的Transform
和我们被绑定到的对象的Transform
。
最有趣的代码是我们创建新的Vector3
、translation
的三行代码。初始值被初始化以匹配m_offsetTransform
的翻译。然后我们添加绑定对象的翻译,并设置我们的Transform
来包含新计算的翻译。最后,我们更新由我们的Transform
对象存储的矩阵。
这段代码将允许我们的摄像机跟随玩家对象通过我们的关卡。当玩家跳跃时,摄像机会随着玩家上升和下降,一旦我们编写代码让玩家通过关卡向右移动,摄像机也会跟着移动。
现在是时候把所有东西绑在一起,给我们的等级加上一个带CameraComponent
的GameObject
。
给 DroidRunnerLevel 添加摄像头
我们的相机对象将被添加到我们的级别对象列表中。让我们看看清单 8-9 中的代码。
清单 8-9。 一更新为DroidRunnerLevel::Initialize
。DroidRunnerLevel.cpp
void DroidRunnerLevel::Initialize(const Vector3& origin)
{
m_sphereGeometry.SetVertexBuffer(sphereVerts);
m_sphereGeometry.SetNumVertices(sizeof(sphereVerts) / sizeof(sphereVerts[0]));
m_sphereGeometry.SetIndexBuffer(sphereIndices);
m_sphereGeometry.SetNumIndices(sizeof(sphereIndices) / sizeof(sphereIndices[0]));
m_sphereGeometry.SetName("android");
m_sphereGeometry.SetNumVertexPositionElements(3);
m_sphereGeometry.SetVertexStride(0);
m_cubeGeometry.SetVertexBuffer(cubeVerts);
m_cubeGeometry.SetNumVertices(sizeof(cubeVerts) / sizeof(cubeVerts[0]));
m_cubeGeometry.SetIndexBuffer(cubeIndices);
m_cubeGeometry.SetNumIndices(sizeof(cubeIndices) / sizeof(cubeIndices[0]));
m_cubeGeometry.SetName("cube");
m_cubeGeometry.SetNumVertexPositionElements(3);
m_cubeGeometry.SetVertexStride(0);
m_origin.Set(origin);
CollisionManager::GetSingleton().AddCollisionBin();
const Vector3 min(-3.0f, -3.0f, -3.0f);
const Vector3 max(3.0f, 3.0f, 3.0f);
TransformComponent* pPlayerTransformComponent = NULL;
const unsigned char tiles[] =
{
EMPTY, EMPTY, EMPTY, EMPTY, AI, AI, AI, AI,
EMPTY, EMPTY, EMPTY, EMPTY, BOX, BOX, BOX, BOX,
EMPTY, PLAYER, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY,
BOX, BOX, BOX, BOX, BOX, BOX, BOX, BOX
};
const unsigned int numTiles = sizeof(tiles) / sizeof(tiles[0]);
const unsigned int numRows = 4;
const unsigned int rowWidth = numTiles / numRows;
for (unsigned int i=0; i<numTiles; ++i)
{
if (tiles[i] == BOX)
{
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
GameObject* pNewObject = new GameObject();
SetObjectPosition(pNewObject, row, column);
AddCollisionComponent(pNewObject, min, max);
Vector4 color(0.0f, 0.0f, 1.0f, 1.0f);
AddRenderableComponent(
pNewObject,
m_cubeGeometry,
m_transformShader,
color);
m_levelObjects.push_back(pNewObject);
}
else if (tiles[i] == PLAYER)
{
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
GameObject* pNewObject = new GameObject();
SetObjectPosition(pNewObject, row, column);
AddMovementComponent(pNewObject);
AddCollisionComponent(pNewObject, min, max);
MovementComponent* pMovementComponent =
component_cast<MovementComponent>(pNewObject);
m_pPlayerCollisionComponent =
component_cast<CollisionComponent>(pNewObject);
if (pMovementComponent && m_pPlayerCollisionComponent)
{
m_pPlayerCollisionComponent->AddEventListener(pMovementComponent);
}
pPlayerTransformComponent = component_cast<TransformComponent>(pNewObject);
Vector4 color(0.0f, 1.0f, 0.0f, 1.0f);
AddRenderableComponent(
pNewObject,
m_sphereGeometry,
m_transformShader,
color);
m_levelObjects.push_back(pNewObject);
}
else if (tiles[i] == AI)
{
const unsigned int row = i / rowWidth;
const unsigned int column = i % rowWidth;
unsigned int patrolEndRow = 0;
unsigned int patrolEndColumn = 0;
for (unsigned int j=i; j<numTiles; ++j)
{
if (tiles[j] != AI)
{
i = j;
--j;
patrolEndRow = j / rowWidth;
patrolEndColumn = j % rowWidth;
break;
}
}
GameObject* pNewObject = new GameObject();
SetObjectPosition(pNewObject, row, column);
AddCollisionComponent(pNewObject, min, max);
AddPatrolComponent(pNewObject, row, column, patrolEndRow, patrolEndColumn);
Vector4 color(1.0f, 0.0f, 0.0f, 1.0f);
AddRenderableComponent(
pNewObject,
m_sphereGeometry,
m_transformShader,
color);
m_levelObjects.push_back(pNewObject);
}
}
// Create a camera object
GameObject* pCameraObject = new GameObject();
pCameraObject->AddComponent<TransformComponent>();
pCameraObject->AddComponent<BoundObjectComponent>();
BoundObjectComponent* pBoundObjectComponent =
component_cast<BoundObjectComponent>(pCameraObject);
assert(pBoundObjectComponent);
pBoundObjectComponent->SetBoundObject(pPlayerTransformComponent);
pBoundObjectComponent->GetTransform().SetTranslation(Vector3(6.0f, 4.25f, -45.0f));
AttachEvent(UPDATE_EVENT, *pBoundObjectComponent);
pCameraObject->AddComponent<CameraComponent>();
CameraComponent* pCameraComponent = component_cast<CameraComponent>(pCameraObject);
assert(pCameraComponent);
const Renderer&renderer = Renderer::GetSingleton();
float width = static_cast<float>(renderer.GetWidth());
float height = static_cast<float>(renderer.GetHeight());
pCameraComponent->SetFrustum(35.0f, width / height, 1.0f, 100.0f);
AttachEvent(POSTUPDATE_EVENT, *pCameraComponent);
m_levelObjects.push_back(pCameraObject);
Renderer* pRenderer = Renderer::GetSingletonPtr();
if (pRenderer)
{
pRenderer->AddShader(&m_transformShader);
}
m_initialized = true;
}
我们更新的DroidRunnerLevel::Initialize
方法现在有必要的代码来从我们在本章中创建的新Components
创建一个相机对象。
第一个变化涉及到缓存一个指向玩家对象的TransformComponent
的指针。这是在创建玩家对象后将相机绑定到玩家对象所必需的。
相机本身需要一个TransformComponent
,一个BoundObjectComponent
,一个CameraComponent
。这是我们创建的第一个没有RenderableComponent
的游戏对象。将BoundObjectComponent
绑定到播放器,并将偏移量设置为Vector3(6.0f, 4.25f, –45.0f)
。该偏移意味着玩家对象在场景中略低于相机,并略偏左。这将允许玩家看到玩家上方一定高度的壁架,以及更多进入视图右侧的场景。
SetFrustum
法 称垂直视场 35 度。纵横比是通过将帧缓冲器的宽度除以帧缓冲器的高度来计算的。框架的宽度和高度由Renderer
保存,当Renderer
初始化时,从EGL
获取。
这导致了另一个需要的变化。之前,我们在Chapter7Task::Start
中初始化关卡。对于第八章,CameraComponent
要求显示器初始化,以便我们可以访问帧缓冲区的宽度和高度。我们在的清单 8-10 中看看我们是如何做到这一点的。
清单 8-10。 第八章任务::更新。第八章任务
void Chapter8Task::Update()
{
if (Renderer::GetSingleton().IsInitialized())
{
if (!m_level.IsInitialized ())
{
Framework::Vector3 levelOrigin(–21.0f, 7.75f, 35.0f);
m_level.Initialize(levelOrigin);
Framework::AttachEvent(POSTUPDATE_EVENT, m_level);
}
Framework::SendEvent(Framework::UPDATE_EVENT);
Framework::SendEvent(Framework::POSTUPDATE_EVENT);
Framework::SendEvent(Framework::RENDER_EVENT);
}
}
如你所见,我们现在不在Update
中做任何工作,直到Renderer
被初始化。我们在DroidRunnerLevel::Initialize
的最后设置了清单 8-9 中的bool
,如果它还没有被完成,我们就在m_level
调用Initialize
。
将我们的更新推迟到渲染器和关卡都已初始化之后,这是我们的应用试图成为 Android 生态系统中的好公民的一部分。我们将看看如何更新我们的渲染器和 Android 类,以便在暂停和恢复时表现得更好。
暂停和恢复时正确的应用行为
到目前为止,当 Android 生态系统向我们发送暂停和恢复事件时,我们的应用一直没有正常运行。清单 4-14 包含了我们的应用用来检测是否应该暂停或恢复的代码,但是我们还没有实际使用这些信息。当我们在执行 OpenGL 应用时收到暂停事件,手机将会破坏我们的 OpenGL 上下文和渲染表面。这对我们的应用没有致命的影响,但是如果您查看来自 LogCat 的输出,您将能够在日志中看到一串红色的输出错误。我们可以通过在此时停止游戏渲染来防止这些错误发生。
添加暂停和恢复事件
我们将通过在事件发生时向广播添加新事件来处理暂停和恢复事件。清单 8-11 显示了新事件。
清单 8-11。PAUSEAPP_EVENT
和RESUMEAPP_EVENT
的定义。EventId.h
static const EventID UPDATE_EVENT = 0;
static const EventID POSTUPDATE_EVENT = 1;
static const EventID RENDER_EVENT = 2;
static const EventID JUMP_EVENT = 3;
static const EventID COLLISION_EVENT = 4;
static const EventID PAUSEAPP_EVENT = 5;
static const EventID RESUMEAPP_EVENT = 6;
我们使用Application::CreateSingletons
中的RegisterEvent
来注册这些事件,如清单 8-12 所示。
清单 8-12。 注册 PAUSEAPP_EVENT 和 RESUMEAPP_EVENT。应用. cpp
void Application::CreateSingletons()
{
new Timer(Task::TIMER_PRIORITY);
new Renderer(m_pAppState, Task::RENDER_PRIORITY);
new EventManager();
new CollisionManager();
RegisterEvent(PAUSEAPP_EVENT);
RegisterEvent(RESUMEAPP_EVENT);
}
现在事件被注册了,清单 8-13 展示了我们如何从android_handle_cmd
内部发送它们。
清单 8-13。 发送PAUSEAPP_EVENT
和RESUMEAPP_EVENT
。Android.cpp
static void android_handle_cmd(struct android_app* app, int32_t cmd)
{
switch (cmd)
{
case APP_CMD_INIT_WINDOW:
{
assert(Renderer::GetSingletonPtr());
Renderer::GetSingleton().Init();
}
break;
case APP_CMD_DESTROY:
{
assert(Renderer::GetSingletonPtr());
Renderer::GetSingleton().Destroy();
}
break;
case APP_CMD_TERM_WINDOW:
{
assert(Renderer::GetSingletonPtr());
Renderer::GetSingleton().Destroy();
}
break;
case APP_CMD_RESUME:
{
SendEvent(RESUMEAPP_EVENT);
}
break;
case APP_CMD_PAUSE:
{
SendEvent(PAUSEAPP_EVENT);
}
break;
}
}
您可以从该类中移除静态方法和m_bPaused
字段,因为我们将不再使用它们。
在渲染器中处理暂停和恢复事件
有兴趣了解应用何时被系统暂停和恢复的对象现在可以附加到这些事件。清单 8-14 显示了我们的Renderer
被更新以继承EventHandler
。
清单 8-14。 为渲染器添加暂停和恢复支持。Renderer.h
class Renderer
: public Task
, public EventHandler
, public Singleton<Renderer>
{
public:
enum FrustumParameters
{
TOP,
BOTTOM,
RIGHT,
LEFT,
NEAR,
FAR,
NUM_PARAMS
};
private:
android_app* m_pState;
EGLDisplay m_display;
EGLContext m_context;
EGLSurface m_surface;
int m_width;
int m_height;
bool m_initialized;
bool m_paused;
typedef std::vector<Shader*> ShaderVector;
typedef ShaderVector::iterator ShaderVectorIterator;
typedef std::vector<Texture*> TextureVector;
typedef TextureVector::iterator TextureVectorIterator;
typedef std::vector<Renderable*> RenderableVector;
typedef RenderableVector::iterator RenderableVectorIterator;
RenderableVector m_renderables;
TextureVector m_textures;
ShaderVector m_shaders;
float m_frustumParameters[NUM_PARAMS];
Matrix4 m_viewMatrix;
Matrix4 m_projectionMatrix;
void Draw(Renderable* pRenderable);
public:
explicit Renderer(android_app* pState, const unsigned int priority);
virtual ∼Renderer();
void Init();
void Destroy();
void AddRenderable(Renderable* pRenderable);
void AddShader(Shader* pShader);
void RemoveShader(Shader* pShader);
void AddTexture(Texture* pTexture);
void RemoveTexture(Texture* pTexture);
// From Task
virtual bool Start();
virtual void OnSuspend();
virtual void Update();
virtual void OnResume();
virtual void Stop();
virtual void HandleEvent(Event* event);
bool IsInitialized() { return m_initialized; }
void SetViewMatrix(const Matrix4& viewMatrix)
{
m_viewMatrix = viewMatrix;
}
const Matrix4& GetViewMatrix() const { return m_viewMatrix; }
void SetFrustum(const float frustumParameters[]);
const Matrix4& GetProjectionMatrix() const { return m_projectionMatrix; }
int GetWidth() const { return m_width; }
int GetHeight() const { return m_height; }
};
你应该在Renderer
的构造函数中初始化m_paused
到false
。清单 8-15 和 8-16 显示了我们在Renderer's Start
和Stop
方法中附加和分离PAUSEAPP_EVENT
和RESUMEAPP_EVENT
。
清单 8-15。 将PAUSEAPP_EVENT
和RESUMEAPP_EVENT
附在Renderer::Start
上。Renderer.cpp
bool Renderer::Start()
{
AttachEvent(PAUSEAPP_EVENT, *this);
AttachEvent(RESUMEAPP_EVENT, *this);
return true;
}
清单 8-16。 脱离Renderer::Stop
中的PAUSEAPP_EVENT
和RESUMEAPP_EVENT
。Renderer.cpp
void Renderer::Stop()
{
DetachEvent(RESUMEAPP_EVENT, *this);
DetachEvent(PAUSEAPP_EVENT, *this);
}
一旦我们附加到事件上,我们必须在HandleEvent
中观察它们,如清单 8-17 所示。
清单 8-17。 Renderer::HandleEvent
,渲染器. cpp
void Renderer::HandleEvent(Event* pEvent)
{
if (pEvent->GetID() == PAUSEAPP_EVENT)
{
m_paused = true;
}
else if (pEvent->GetID() == RESUMEAPP_EVENT)
{
m_paused = false;
}
}
当我们收到这些事件时,我们分别将m_paused
字段设置为true
或false
。现在我们更新 Renderer::Update 方法来防止应用暂停时的渲染。清单 8-18 显示了这一点。
清单 8-18。 在Renderer::Update
中暂停渲染。Renderer.cpp
void Renderer::Update()
{
if (m_initialized &&!m_paused)
{
glEnable(GL_DEPTH_TEST);
glClearColor(0.95f, 0.95f, 0.95f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
for (RenderableVectorIterator iter = m_renderables.begin();
iter != m_renderables.end();
++iter)
{
Renderable* pRenderable = *iter;
if (pRenderable)
{
Draw(pRenderable);
}
}
eglSwapBuffers(m_display, m_surface);
m_renderables.clear();
}
}
将此代码添加到渲染器后,您将不会再看到与缺少 OpenGL 上下文和表面相关的错误消息。如前所述,如果没有这一点,应用就不会崩溃,但通常我们的应用最好按照 Android 的预期运行,以确保我们与所有过去、现在和未来的 Android 版本和设备驱动程序完全兼容。
在我们的小弯路之后,是时候回到相机的工作中了。下一部分将使用我们相机中的信息,这些信息与我们的相机位置和它能看到的东西有关,来优化我们的渲染过程。我们将实现的特定技术被称为视图截锥剔除。
视图截锥剔除
现代 GPU 是一个非常高效的协处理器,可以对我们提供给它的数据进行计算,比 CPU 快得多。这是因为 GPU 被设计为大规模并行,并且专门设计为除了搅动数据流之外什么也不做。我们的游戏逻辑代码是为手机中更加灵活的 CPU 编写的。CPU 的好处是它可以执行更广泛的任务,这些任务也不一定适合分解成小块。这意味着在 CPU 上执行的代码可以比 GPU 获得更多关于我们游戏世界的信息。这包括我们的相机对象和场景中的其他对象。
我们可以利用这一点。我们的相机对象可以用一个平截头体来表示。图 8-5 显示了三维空间中摄像机截锥的形状。
图 8-5 。使用透视投影时的相机截锥
图 8-5 显示了透视摄像机截锥的形状。平截头体是截棱锥。当我们在场景中循环遍历Renderables
的向量时,我们可以做一个测试来确定物体的一部分是否在摄像机截锥内;如果是,我们就画出这个物体。如果我们可以检测到整个对象都在截锥之外,我们可以丢弃这个对象,永远不要将其发送到 GPU。在图 8-5 的中,直线相交的点是摄像机的位置,截锥指向摄像机变换矩阵的 z 轴。
在我们的渲染器中实现平截头体剔除的第一个任务是存储每帧的相机矩阵和平截头体参数。清单 8-19 显示了我们必须对Renderer
进行的更新。
清单 8-19。 给Renderer
添加平截体剔除支持。Renderer.h
class Renderer
: public Task
, public EventHandler
, public Singleton<Renderer>
{
public:
enum FrustumParameters
{
TOP,
BOTTOM,
RIGHT,
LEFT,
NEAR,
FAR,
NUM_PARAMS
};
private:
android_app* m_pState;
EGLDisplay m_display;
EGLContext m_context;
EGLSurface m_surface;
int m_width;
int m_height;
bool m_initialized;
bool m_paused;
typedef std::vector<Shader*> ShaderVector;
typedef ShaderVector::iterator ShaderVectorIterator;
typedef std::vector<Texture*> TextureVector;
typedef TextureVector::iterator TextureVectorIterator;
typedef std::vector<Renderable*> RenderableVector;
typedef RenderableVector::iterator RenderableVectorIterator;
RenderableVector m_renderables;
TextureVector m_textures;
ShaderVector m_shaders;
float m_frustumParameters[NUM_PARAMS];
Matrix4 m_cameraMatrix;
Matrix4 m_viewMatrix;
Matrix4 m_projectionMatrix;
void Draw(Renderable* pRenderable);
void BuildFrustumPlanes(Plane frustumPlanes[]);
bool ShouldDraw(Renderable* pRenderable, Plane frustumPlanes[]) const;
public:
explicit Renderer(android_app* pState, const unsigned int priority);
virtual ∼Renderer();
void Init();
void Destroy();
void AddRenderable(Renderable* pRenderable);
void AddShader(Shader* pShader);
void RemoveShader(Shader* pShader);
void AddTexture(Texture* pTexture);
void RemoveTexture(Texture* pTexture);
// From Task
virtual bool Start();
virtual void OnSuspend();
virtual void Update();
virtual void OnResume();
virtual void Stop();
virtual void HandleEvent(Event* event);
bool IsInitialized() { return m_initialized; }
void SetCameraMatrix(const Matrix4&cameraMatrix)
{
m_cameraMatrix = cameraMatrix;
}
const Matrix4&GetCameraMatrix() const { return m_cameraMatrix; }
void SetViewMatrix(const Matrix4& viewMatrix) { m_viewMatrix = viewMatrix; }
const Matrix4& GetViewMatrix() const { return m_viewMatrix; }
void SetFrustum(const float frustumParameters[]);
const Matrix4& GetProjectionMatrix() const { return m_projectionMatrix; }
int GetWidth() const { return m_width; }
int GetHeight() const
{
return m_height;
}
};
我们的Renderer
现在有另一个Matrix4
来存储当前摄像机的矩阵。我们还有两个新方法,BuildFrustumPlanes
和ShouldDraw
。
平截头体剔除利用了几何平面的属性。平面可用于将三维空间分成两半。然后,我们可以使用一个简单的点积来确定我们测试的点是在平面的前面还是后面。如果你现在不明白这背后的数学原理,不要担心;本书在http://www.apress.com/9781430258308
提供了代码,附录 D 涵盖了代码示例中包含的数学类。我建议您通读附录和源代码,直到弄清楚这些平面是如何工作的。
BuildFrustumPlanes
将用于构建六个平面(一个用于近裁剪平面,一个用于远裁剪平面,一个代表平截头体的四个边:顶、底、左、右)。我们希望这些平面中的每一个都有一个正的半空间,指向平截头体的中心。平面的正半空间是平面法线指向的那一边。清单 8-20 显示了BuildFrustumPlanes
的代码。
清单 8-20。 Renderer::BuildFrustumPlanes
,渲染器. cpp
void Renderer::BuildFrustumPlanes(Plane frustumPlanes[])
{
// Get the camera orientation vectors and position as Vector3
Vector3 cameraRight(
m_cameraMatrix.m_m[0],
m_cameraMatrix.m_m[1],
m_cameraMatrix.m_m[2]);
Vector3 cameraUp(
m_cameraMatrix.m_m[4],
m_cameraMatrix.m_m[5],
m_cameraMatrix.m_m[6]);
Vector3 cameraForward(
m_cameraMatrix.m_m[8],
m_cameraMatrix.m_m[9],
m_cameraMatrix.m_m[10]);
Vector3 cameraPosition(
m_cameraMatrix.m_m[12],
m_cameraMatrix.m_m[13],
m_cameraMatrix.m_m[14]);
// Calculate the center of the near plane
Vector3 nearCenter = cameraForward;
nearCenter.Multiply(m_frustumParameters[NEAR]);
nearCenter.Add(cameraPosition);
// Calculate the center of the far plane
Vector3 farCenter = cameraForward;
farCenter.Multiply(m_frustumParameters[FAR]);
farCenter.Add(cameraPosition);
// Calculate the normal for the top plane
Vector3 towardsTop = cameraUp;
towardsTop.Multiply(m_frustumParameters[TOP]);
towardsTop.Add(nearCenter);
towardsTop.Subtract(cameraPosition);
towardsTop.Normalize();
towardsTop = cameraRight.Cross(towardsTop);
frustumPlanes[TOP].BuildPlane(cameraPosition, towardsTop);
// Calculate the normal for the bottom plane
Vector3 towardsBottom = cameraUp;
towardsBottom.Multiply(m_frustumParameters[BOTTOM]);
towardsBottom.Add(nearCenter);
towardsBottom.Subtract(cameraPosition);
towardsBottom.Normalize();
towardsBottom = towardsBottom.Cross(cameraRight);
frustumPlanes[BOTTOM].BuildPlane(cameraPosition, towardsBottom);
// Calculate the normal for the right plane
Vector3 towardsRight = cameraRight;
towardsRight.Multiply(m_frustumParameters[RIGHT]);
towardsRight.Add(nearCenter);
towardsRight.Subtract(cameraPosition);
towardsRight.Normalize();
towardsRight = towardsRight.Cross(cameraUp);
frustumPlanes[RIGHT].BuildPlane(cameraPosition, towardsRight);
// Calculate the normal for the left plane
Vector3 towardsLeft = cameraRight;
towardsLeft.Multiply(m_frustumParameters[LEFT]);
towardsLeft.Add(nearCenter);
towardsLeft.Subtract(cameraPosition);
towardsLeft.Normalize();
towardsLeft = cameraUp.Cross(towardsLeft);
frustumPlanes[LEFT].BuildPlane(cameraPosition, towardsLeft);
Vector3 towardsNear = cameraForward;
frustumPlanes[NEAR].BuildPlane(nearCenter, towardsNear);
Vector3 towardsFar = cameraForward;
towardsFar.Negate();
frustumPlanes[FAR].BuildPlane(farCenter, towardsFar);
}
正如我们所看到的,在BuildFrustumPlanes
方法中有一段代码用于摄像机截锥的每一侧。近平面和远平面可以优化,这意味着计算它们所需的代码更少。
按顺序浏览该方法将显示我们如何得到代表平截头体的六个平面。首先,我们解构相机矩阵。如果你还记得,在第六章中,我们讨论过旋转矩阵是正交矩阵。这意味着矩阵的每一行代表 3D 空间中的一个轴。一个 3x3 矩阵第一行代表右向量(x 轴),第二行代表上向量(y 轴),第三行代表 at 向量(z 轴)。代码的第一部分从矩阵中提取每个轴的三个法向量,以及矩阵的平移分量。每个都转换成一个Vector3
。
然后我们计算远近平面的中心点。相机矩阵的 at 向量是一个单位法线,因此将其乘以到近平面的距离,就得到近平面相对于原点的中心点(0,0,0 处的点)。然后,我们添加相机的位置,以获得相对于相机位置的近平面的中心。我们对远平面重复这个过程,但是使用到远平面的距离。
现在我们应该回顾一下我们是如何计算清单 8-2 中的截锥参数的。顶部参数通过将到近平面的距离乘以垂直视场的半角来计算。这其实就是利用了直角三角形和三角学。图 8-6 显示了将视野一分为二形成的两个直角三角形的侧视图。
图 8-6 。相机位置到近平面的侧面轮廓
通过使用三角学,我们能够通过使用 tan 函数计算近平面上半部分的垂直高度。我们可以再次使用这个值和相机变换矩阵中的上方向向量来计算代表平截头体顶部的平面的法线。
清单 8-21 是取自清单 8-20 中已经显示的BuildFrustumPlanes
函数的一段代码。
清单 8-21。 计算圆台的顶面。Renderer.cpp
// Calculate the normal for the top plane
Vector3 towardsTop = cameraUp;
towardsTop.Multiply(m_frustumParameters[TOP]);
towardsTop.Add(nearCenter);
towardsTop.Subtract(cameraPosition);
towardsTop.Normalize();
towardsTop = cameraRight.Cross(towardsTop);
frustumPlanes[TOP].BuildPlane(cameraPosition, towardsTop);
我们首先将相机的上方向向量分配给Vector3
、towardsTop
。 图 8-7 显示了原点处的这个矢量:虚线表示剖面中的 y 轴和 z 轴,y 向上,z 向右。x 轴指向远离你的方向。我们将逐步完成这一过程;在每个阶段,虚线将显示前面的步骤。
图 8-7 。创建顶部平截头体平面的第一步
然后我们将这个向量乘以近平面上半部分的高度,它存储在m_frustumParameters[TOP]
中,如图 8-8 中的所示。
图 8-8 。相机上方向向量乘以平截头体上半部分的高度
图 8-9 显示了下一步,将该矢量添加到nearCenter
位置。
图 8-9 。正在将近中心位置添加到我们的矢量中
接下来的两个步骤很难用数字来表示,所以我们将通过它们来讨论。向量减法很有用,因为它允许我们在 3D 空间中移动向量。在这个过程中的这个时刻,我们已经设法得到了一个向量,它代表了从相机的位置指向近平面顶部的线。
我们现在想把我们的矢量转换成指向这个方向的单位法线。为了实现这一点,我们需要通过减去相机位置将向量移动到原点。请记住,在这个过程的开始,我们将nearCenter
向量添加到新的上方向向量中,并且最初使用cameraPosition
向量来计算nearCenter
向量。这就是为什么我们可以通过减去cameraPosition
将向量移回原点。现在我们在矢量上调用Normalize
来创建一个指向近平面顶部的单位法线。
我们现在得到的矢量指向平截头体的顶面。相机的右向量也在这个平面上。我们想要的是一个指向平截头体的单位向量,我们可以用叉积得到其中之一。两个单位矢量的叉积是垂直于两个矢量所在平面的另一个单位矢量。结果向量的方向由右手定则决定。右手定则可能很难让你理解,所以我用螺丝刀的类比来计算我的叉积会给我哪个方向。
如果你以前用过螺丝刀,你会知道顺时针转动螺丝会导致螺丝移入物体。逆时针转动会导致螺钉脱出。叉积与此相反:顺时针旋转会产生一个朝向你的矢量,逆时针旋转会远离你。我通过想象螺钉指向我,头部在另一端来想象这个。如果螺丝顺时针旋转,意味着它会离我更近,如果逆时针旋转,它会远离我。逻辑有点混乱,但它一直很好地服务于我。
你可以看到我们如何通过放置你的左手,手掌向下,拇指指向右边,食指指向前方,中指向下,来获得指向平截头体的矢量。你的拇指代表cameraRight
向量,你的食指代表towardsTop
向量。现在旋转你的手,让你的拇指移动到你食指的位置。这就是我们正在做的cameraRight.Cross(towardsTop)
操作,产生的矢量就是我们的平面法线。我们现在使用BuildPlane
方法建造我们的飞机。
现在使用它们各自的平截头体参数和适当的叉积方向对每个其他平面重复该过程。有趣的是,底部平面的叉积与顶部平面的叉积相反。这就是我们如何实现指向两边截锥的向量。左右平面也是如此。
计算远近平面的过程要简单得多。这些平面已经沿着现有的相机指向矢量。近平面指向与相机相同的方向,远平面指向相反的方向。我们在BuildPlane
方法中使用的平面上的点分别是nearCenter
和farCenter
点,而不是cameraPosition
向量。
既然我们已经构建了我们的平截头体平面,我们可以在调用Draw
之前检测一个对象是否位于平截头体内。
视锥剔除测试
为了能够针对平截头体测试我们的对象,我们必须首先知道它们的边界在空间中的位置。幸运的是,我们可以在这个任务中重用来自CollisionComponent
的数据。我们从给清单 8-22 中的Renderable
类添加最小和最大边界向量开始。
清单 8-22。 给Renderable
添加界限。可渲染. h
class Renderable
{
private:
Geometry* m_pGeometry;
Shader* m_pShader;
Transform m_transform;
Vector4 m_color;
Vector3 m_min;
Vector3 m_max;
bool m_useBounds;
public:
Renderable();
∼Renderable();
void SetGeometry(Geometry* pGeometry);
Geometry* GetGeometry();
void SetShader(Shader* pShader);
Shader* GetShader();
Transform& GetTransform() { return m_transform; }
Vector4& GetColor() { return m_color; }
void SetBoundMin(const Vector3&min) { m_min = min; }
const Vector3&GetBoundMin() const { return m_min; }
void SetBoundMax(const Vector3&max) { m_max = max; }
const Vector3&GetBoundMax() const { return m_max; }
void SetUseBounds(bool enabled) { m_useBounds = enabled; }
bool GetUseBounds() const { return m_useBounds; }
bool IsInitialized() const
{
return m_pGeometry && m_pShader;
}
};
在清单 8-23 的中,我们更新了方法RenderableComponent::HandleEvent
来设置当我们添加对象到渲染列表时的边界。
清单 8-23。 更新RenderableComponent::HandleEvent
。RenderableComponent.cpp
void RenderableComponent::HandleEvent(Event* pEvent)
{
assert(pEvent);
if (pEvent->GetID() == RENDER_EVENT)
{
TransformComponent* pTransformComponent = component_cast<TransformComponent>(GetOwner());
if (pTransformComponent)
{
m_renderable.GetTransform().Clone(pTransformComponent->GetTransform());
}
CollisionComponent* pCollisionComponent = component_cast<CollisionComponent>(GetOwner());
if (pCollisionComponent)
{
m_renderable.SetBoundMin(pCollisionComponent->GetMin());
m_renderable.SetBoundMax(pCollisionComponent->GetMax());
m_renderable.SetUseBounds(true);
}
else
{
m_renderable.SetUseBounds(false);
}
assert(Renderer::GetSingletonPtr());
Renderer::GetSingleton().AddRenderable(&m_renderable);
}
}
如果被渲染的对象有一个CollisionComponent
对象,现在HandleEvent
设置Renderable
的边界。
现在我们可以将代码添加到Renderer::Update
中,以确定对象是否在截锥内。清单 8-24 显示了这次更新。
清单 8-24。 更新Renderer::Update
。Renderer.cpp
void Renderer::Update()
{
if (m_initialized && !m_paused)
{
Plane frustumPlanes[NUM_PARAMS];
BuildFrustumPlanes(frustumPlanes);
glEnable(GL_DEPTH_TEST);
glClearColor(0.95f, 0.95f, 0.95f, 1);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
for (RenderableVectorIterator iter = m_renderables.begin();
iter != m_renderables.end();
++iter)
{
Renderable* pRenderable = *iter;
if (pRenderable)
{
bool bDraw = ShouldDraw(pRenderable, frustumPlanes);
if (bDraw)
{
Draw(pRenderable);
}
}
}
eglSwapBuffers(m_display, m_surface);
m_renderables.clear();
}
}
现在渲染器的Update
方法在每次被调用时都会构建新的平截头体平面。这是因为相机会移动每一帧,我们必须构建新的平面来表示这一点。
我们现在也确定一个对象是否应该使用ShouldDraw
方法 来渲染。清单 8-25 展示了这个方法的内容。
清单 8-25。 Renderer::ShouldDraw
,渲染器. cpp
bool Renderer::ShouldDraw(Renderable* pRenderable, Plane frustumPlanes[]) const
{
bool shouldDraw = true;
if (pRenderable && pRenderable->GetUseBounds())
{
shouldDraw = false;
Vector3 min = pRenderable->GetBoundMin();
min.Add(pRenderable->GetTransform().GetTranslation());
Vector3 max = pRenderable->GetBoundMax();
max.Add(pRenderable->GetTransform().GetTranslation());
static const unsigned int NUM_POINTS = 8;
Vector3 points[NUM_POINTS];
points[0] = min;
points[1] = max;
points[2].m_x = min.m_x;
points[2].m_y = min.m_y;
points[2].m_z = max.m_z;
points[3].m_x = min.m_x;
points[3].m_y = max.m_y;
points[3].m_z = max.m_z;
points[4].m_x = max.m_x;
points[4].m_y = min.m_y;
points[4].m_z = max.m_z;
points[5].m_x = max.m_x;
points[5].m_y = max.m_y;
points[5].m_z = min.m_z;
points[6].m_x = min.m_x;
points[6].m_y = max.m_y;
points[6].m_z = min.m_z;
points[7].m_x = max.m_x;
points[7].m_y = min.m_y;
points[7].m_z = min.m_z;
for (unsigned int j=0; j<NUM_POINTS; ++j)
{
unsigned int numPlanesInFront = 0;
for (unsigned int i=0; i<6; ++i)
{
if (!frustumPlanes[i].IsInFront(points[j]))
{
break;
}
++numPlanesInFront;
}
if (numPlanesInFront == 6)
{
shouldDraw = true;
break;
}
}
}
return shouldDraw;
}
ShouldDraw
首先检查Renderable
指针是否有效,对象是否有我们可以测试的边界。如果这是真的,我们设置shouldDraw
为假;这个物体现在只有通过我们的测试才会被渲染。然后我们得到最小和最大边界,加上物体的平移,这样我们就可以在世界空间中定位边界。最小和最大位置代表长方体的两个极限角;我们使用 min 和 max 元素创建其他六个角,并将所有八个角存储到 points 数组中。
第一个 for 循环依次遍历这些点。然后,一个内部循环针对六个平截头体平面中的每一个测试该点。对于在平截头体内部的给定点,针对平截头体平面的所有六个测试都必须通过。记住一个平面把世界分成两半,平面前面是正空间,后面是负空间。如果一个点落在任何一个平面的后面,它就不在相机的截锥内。一旦我们检测到该点不在截锥内,我们就中断,以确保我们不会浪费时间来测试不必要的平面。
一旦一个对象通过了所有六个平面的测试,shouldDraw
布尔值被设置回true
并且函数返回。
我们现在已经成功实现了一个优化策略,只有那些真正有助于场景最终渲染的对象才会被 GPU 处理。这将允许我们在广告标题中实现更高的帧速率或呈现更详细的场景。
摘要
这一章已经让我们快速浏览了我们可以用游戏中的相机实现的东西。你现在应该明白虚拟相机对任何游戏来说是多么的重要。能够在 3D 空间中移动是每个现代游戏不可或缺的功能。就相机开发而言,我们只是触及了皮毛。如今的商业游戏使用复杂的物理和人工智能来控制他们的相机,尤其是在第三人称游戏中,即使这样,许多游戏玩家也会对特定游戏中相机的行为方式感到沮丧。制造好的相机是一项复杂但有益的工作,希望这一章已经给了你一个很好的主题介绍。
我们还看到了如何使用相机提供的信息来加速我们的渲染过程。一旦我们想要创建任何大尺寸的关卡和世界,平截头体剔除就成了一种无价的加速算法。如果试图在任何给定时间渲染世界上的每个物体,像湮没这样的开放世界游戏在今天的硬件上是不可能的。游戏团队很大一部分工作就是想办法用有限的资源创造尽可能详细的世界。这些技术在移动设备上尤其重要,因为从 CPU 向 GPU 传输数据的可用内存带宽是有限的。我们可以更有效地使用这个带宽,不要把在当前帧中看不到的对象转移到我们的 GPU。
现在我们已经有了基本的游戏,是时候让它看起来更吸引人了。到目前为止,我们这个世界上的所有东西都是用一种平面颜色着色的。在下一章,我们将研究模拟照明和材质,让我们的场景更有趣,看起来更像 3D 游戏。`
九、照明和材质
编程团队可以通过灯光和材质模型的应用,帮助美术团队实现他们渴望的游戏外观。这些年来,许多技术被开发出来赋予游戏一种特定的风格;2000 年发行的《喷气研磨电台》就是这样一款游戏。 Jet Grind Radio 因其单元格阴影图形风格而闻名,这使其在当时的竞争中脱颖而出。
鉴于它是在没有顶点和碎片着色器的平台上开发的,因此 Jet Grind Radio 的风格更加令人印象深刻。在过去的十年中,随着消费类硬件的发展,特殊的光照和材质效果在视频游戏中变得更加普遍,这使得实时着色器成为可能。这些功能现在在移动设备上也很普遍,着色器的灵活性为游戏开发人员提供了高级图形技术的使用。
在这一章中,我们将会看到基本的照明和材质技术,它们是用来制作更高级效果的基础。本章中的基本灯光和材质是 OpenGL ES 1.0 中固定功能灯光和材质的再现。
注意 OpenGL ES 2.0 没有任何内置的光照支持;由程序员使用着色器来实现他们想要的模型。
首先,我们来看看光线对于程序员是如何定义的,以及光线是如何通过材质与我们的游戏实体相互作用的。
一个基本的灯光和材质模型
自然界中的光是一种复杂的现象。幸运的是,作为游戏开发者,我们不必用物理现实来模拟光线;相反,我们使用一个模型来模拟光的作用以及它如何与场景中的物体相互作用。
在最基本的层面上,无论我们是在顶点着色器还是片段着色器中处理灯光,我们的光源最终都会归结为一个方向和一个颜色,它会使用某种形式的方程来影响我们对象的输出颜色。
光的属性构成了我们照明方程的一组输入;另一组输入是对象本身的属性。我们将这些对象属性称为材质。当讨论材质时,我们说我们正在将材质应用于物体。
现代游戏引擎中的材质定义包括用于对模型进行着色的着色器程序、要应用的纹理以及任何其他特殊效果和渲染状态,这些都是正确渲染对象所必需的。
这是照明的简要概述,当你得知模型的实际实现稍微复杂一些时,你不会感到惊讶。我们的灯光和材质将被用来计算光线的三种不同成分:环境光,漫射光和镜面反射光。在这一章中,我们将介绍这些组件中的每一个,并看看每个组件的效果。
一旦我们看了照明等式的组成部分,我们将看一下引擎中光源的三种不同表示。这些将是平行光,位置光和聚光灯。
逐顶点或逐片段着色
OpenGL ES 1.0 中使用的着色模型称为 Blinn-Phong 着色模型。它以吉姆·布林和裴祥风的名字命名。Phong 在 1973 年描述了他的照明模型,这个模型后来被 Blinn 修改,并由此得名。
模型本身描述了我们将在本章中使用的公式,来计算应用于对象的颜色的环境、漫射和镜面反射分量。我们必须考虑的模型的另一个方面是我们希望计算有多精确。如果我们在顶点着色器中计算光线、表面和材质之间交互结果的颜色,颜色将存储在一个变量中,然后在多边形的其余部分进行插值。这种形式的插值颜色被称为 Gouraud 阴影,也是以该技术的作者 Henri Gouraud 的名字命名的。
这种技术可以给出可接受的结果,但通常可以很容易地看到多边形的边缘。另一种形式的插值是冯着色模型,这是由裴祥风描述,以及他的着色模型技术的其余部分。这种形式的插值包括对片段表面顶点的法线进行插值,并计算片段着色器中每个单独像素的照明颜色。这给出了更好的结果,但是显然计算量更大。
对我们来说幸运的是,在两种情况下照明的等式是相同的,因为与商业游戏相比,我们有一个非常简单的场景,我们将使用 Blinn-Phong 照明模型和 Phong 着色来给我们最好的结果。
在我们开始执行场景中的照明任务之前,我们必须创建一个可以表示材质的类。
代表材质
Material
类将用于存储所有与我们场景中物体表面最终外观相关的信息。这包括任何着色器,纹理和颜色,我们将使用它们来表示照明等式的各个部分。清单 9-1 描述了Material
类。我们还没有涵盖这个类的字段将用于什么,所以现在不要担心它们。
清单 9-1。Material
类声明。材质. h
class Material
{
private:
Shader* m_pShader;
Texture* m_pTexture;
Vector4 m_ambientColor;
Vector4 m_diffuseColor;
Vector4 m_specularColor;
float m_specularExponent;
public:
Material()
: m_pShader(NULL)
, m_pTexture(NULL)
, m_specularExponent(0.0f)
{
}
∼Material()
{
}
void SetShader(Shader* pShader)
{
m_pShader = pShader;
}
Shader* GetShader() const
{
return m_pShader;
}
void SetTexture(Texture* pTexture)
{
m_pTexture = pTexture;
}
Texture* GetTexture() const
{
return m_pTexture;
}
void SetAmbientColor(Vector4 ambientColor)
{
m_ambientColor = ambientColor;
}
const Vector4& GetAmbientColor() const
{
return m_ambientColor;
}
void SetDiffuseColor(Vector4 diffuseColor)
{
m_diffuseColor = diffuseColor;
}
const Vector4& GetDiffuseColor() const
{
return m_diffuseColor;
}
void SetSpecularColor(Vector4 specularColor)
{
m_specularColor = specularColor;
}
const Vector4& GetSpecularColor() const
{
return m_specularColor;
}
void SetSpecularExponent(float specularExponent)
{
m_specularExponent = specularExponent;
}
const float GetSpecularExponent() const
{
return m_specularExponent;
}
};
如你所见,我们的Material
类只是一个容器,用来存储一些我们可以与对象关联的数据。我们通过将它添加到我们的Renderable
类中来做到这一点。清单 9-2 展示了我们新的Renderable
类,用一个Material
代替了之前的着色器指针和颜色Vector4
字段。
清单 9-2。 添加一个Material
来渲染。可渲染. h
class Renderable
{
private:
Geometry* m_pGeometry;
Material* m_pMaterial;
Transform m_transform;
Vector3 m_min;
Vector3 m_max;
bool m_useBounds;
public:
Renderable();
∼Renderable();
void SetGeometry(Geometry* pGeometry);
Geometry* GetGeometry();
void SetMaterial(Material* pMaterial);
Material* GetMaterial();
Transform& GetTransform() { return m_transform; }
void SetBoundMin(const Vector3& min) { m_min = min; }
const Vector3& GetBoundMin() const { return m_min; }
void SetBoundMax(const Vector3& max) { m_max = max; }
const Vector3& GetBoundMax() const { return m_max; }
void SetUseBounds(bool enabled) { m_useBounds = enabled; }
bool GetUseBounds() const { return m_useBounds; }
bool IsInitialized() const
{
return m_pGeometry
&&
m_pMaterial;
}
};
inline Renderable::Renderable()
: m_pGeometry(NULL)
, m_pMaterial(NULL)
{
}
inline Renderable::∼Renderable()
{
}
inline void Renderable::SetGeometry(Geometry* pGeometry)
{
m_pGeometry = pGeometry;
}
inline Geometry* Renderable::GetGeometry()
{
return m_pGeometry;
}
inline void Renderable::SetMaterial(Material* pMaterial)
{
m_pMaterial = pMaterial;
}
inline Material* Renderable::GetMaterial()
{
return m_pMaterial;
}
现在我们有了一个可以为我们的可渲染对象存储材质属性的类,我们将看看如何在着色器中使用这些属性来为我们的场景添加光线。
环境照明
照明模型的环境成分用于模拟我们场景中的背景光。你可以把这想象成一个有窗户的房间里的日光。
房间里没有任何东西被光源直接照亮;然而,一切都有光反弹。这是因为来自太阳的光足够强大,它可以从许多物体上反弹回来,但仍然继续前进,照亮更多的物体。一旦太阳下山,一切都暗了很多,因为周围反射的环境光少了很多。
从这个意义上来说,环境光被视为基本级别的照明,以确保场景中的对象不会看起来完全是黑色的。环境照明组件的等式非常简单。
最终颜色=环境光颜色×环境颜色
到目前为止,我们在TransformShader
中使用的当前渲染实际上相当于环境光值为(1,1,1,1),它指定我们的对象应该被环境光完全照亮;图 9-1 显示了游戏在没有灯光的情况下是如何渲染的。
图 9-1 。《机器人赛跑者》中一个没有灯光的场景
我们现在将通过添加一个新的着色器来改变这一点。清单 9-3 显示了TransformAmbientShader
类。
清单 9-3。TransformAmbientShader
类声明。transformambientsharder . h
class TransformAmbientShader
: public Shader
{
private:
GLint m_transformUniformHandle;
GLint m_positionAttributeHandle;
GLint m_colorUniformHandle;
GLint m_ambientLightUniformHandle;
public:
TransformAmbientShader();
virtual ∼TransformAmbientShader();
virtual void Link();
virtual void Setup(Renderable& renderable);
};
我们的TransformAmbientShader
班和我们的TransformShader
几乎一模一样;唯一增加的是一个新的字段来存储环境光制服的句柄。
TransformAmbientShader
的构造函数包含了我们新着色器的新 GLSL 代码。片段着色器包含一个新的统一,u_vAmbientLight
。这个制服是一个vec4
,包含环境光常数。该常数与片段颜色相乘,以确定存储在gl_FragColor
中的片段的环境颜色。清单 9-4 显示了新的 GLSL 代码。
清单 9-4。 TransformAmbientShader's
建造师。transformambientsharder . CPP
TransformAmbientShader::TransformAmbientShader()
{
m_vertexShaderCode =
"uniform mat4 u_mModel; \n"
"attribute vec4 a_vPosition; \n"
"void main(){ \n"
" gl_Position = u_mModel * a_vPosition; \n"
"} \n";
m_fragmentShaderCode =
"precision mediump float; \n"
" \n"
"uniform vec4 u_vColor; \n"
"uniform vec4 u_vAmbientLight; \n"
" \n"
"void main(){ \n"
" gl_FragColor = u_vAmbientLight * u_vColor; \n"
"} \n";
}
我们需要获得新制服的句柄,我们在TransformAmbientShader::Link
中这样做,如清单 9-5 所示。
清单 9-5。 TransformAmbientShader::Link
。transformambientsharder . CPP
void TransformAmbientShader::Link()
{
Shader::Link();
m_transformUniformHandle = glGetUniformLocation(m_programId, "u_mModel");
m_positionAttributeHandle = glGetAttribLocation(m_programId, "a_vPosition");
m_colorUniformHandle = glGetUniformLocation(m_programId, "u_vColor");
m_ambientLightUniformHandle = glGetUniformLocation(m_programId, "u_vAmbientLight");
}
这个新着色器的Setup
方法也类似于TransformShader
的方法。只有设置环境光颜色所需的行是新的。清单 9-6 突出了这些变化。
清单 9-6。 TransformAmbientShader::Setup
。transformambientsharder . CPP
void TransformAmbientShader::Setup(Renderable& renderable)
{
Geometry* pGeometry = renderable.GetGeometry();
if (pGeometry)
{
Shader::Setup(renderable);
Renderer& renderer = Renderer::GetSingleton();
const Matrix4& viewMatrix = renderer.GetViewMatrix();
const Matrix4& projectionMatrix = renderer.GetProjectionMatrix();
Matrix4 modelViewMatrix;
renderable.GetTransform().GetMatrix().Multiply(viewMatrix, modelViewMatrix);
Matrix4 modelViewProjectionMatrix;
modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);
glUniformMatrix4fv(m_transformUniformHandle, 1, false, modelViewProjectionMatrix.m_m);
glVertexAttribPointer(
m_positionAttributeHandle,
pGeometry->GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
pGeometry->GetVertexStride(),
pGeometry->GetVertexBuffer());
glEnableVertexAttribArray(m_positionAttributeHandle);
const Vector4& color = renderable.GetMaterial()->GetAmbientColor();
glUniform4f(m_colorUniformHandle, color.m_x, color.m_y, color.m_z, color.m_w);
const Vector4
&
ambientLightColor = renderer.GetAmbientLightColor();
glUniform4f(m_ambientLightUniformHandle,
ambientLightColor.m_x,
ambientLightColor.m_y,
ambientLightColor.m_z,
ambientLightColor.m_w);
}
}
这个清单表明,我们还必须向我们的Renderer
添加一些新方法。清单 9-7 显示了这个小的增加;只需向Renderer
类添加一个新的private Vector4
字段,并添加设置和获取值的方法。
清单 9-7。 给渲染器添加 m_ambientLightColor。Renderer.h
class Renderer
: public Task
, public EventHandler
, public Singleton<Renderer>
{
public:
enum FrustumParameters
{
TOP,
BOTTOM,
RIGHT,
LEFT,
NEAR,
FAR,
NUM_PARAMS
};
private:
android_app* m_pState;
EGLDisplay m_display;
EGLContext m_context;
EGLSurface m_surface;
int m_width;
int m_height;
bool m_initialized;
bool m_paused;
typedef std::vector<Shader*> ShaderVector;
typedef ShaderVector::iterator ShaderVectorIterator;
typedef std::vector<Texture*> TextureVector;
typedef TextureVector::iterator TextureVectorIterator;
typedef std::vector<Renderable*> RenderableVector;
typedef RenderableVector::iterator RenderableVectorIterator;
RenderableVector m_renderables;
TextureVector m_textures;
ShaderVector m_shaders;
float m_frustumParameters[NUM_PARAMS];
Matrix4 m_cameraMatrix;
Matrix4 m_viewMatrix;
Matrix4 m_projectionMatrix;
void Draw(Renderable* pRenderable);
void BuildFrustumPlanes(Plane frustumPlanes[]);
bool ShouldDraw(Renderable* pRenderable, Plane frustumPlanes[]) const;
Vector4 m_ambientLightColor;
public:
explicit Renderer(android_app* pState, const unsigned int priority);
virtual ∼Renderer();
void Init();
void Destroy();
void AddRenderable(Renderable* pRenderable);
void AddShader(Shader* pShader);
void RemoveShader(Shader* pShader);
void AddTexture(Texture* pTexture);
void RemoveTexture(Texture* pTexture);
// From Task
virtual bool Start();
virtual void OnSuspend();
virtual void Update();
virtual void OnResume();
virtual void Stop();
virtual void HandleEvent(Event* event);
bool IsInitialized() { return m_initialized; }
void SetCameraMatrix(const Matrix4& cameraMatrix)
{
m_cameraMatrix = cameraMatrix;
}
const Matrix4& GetCameraMatrix() const { return m_cameraMatrix; }
void SetViewMatrix(const Matrix4& viewMatrix)
{
m_viewMatrix = viewMatrix;
}
const Matrix4& GetViewMatrix() const { return m_viewMatrix; }
void SetFrustum(const float frustumParameters[]);
const Matrix4& GetProjectionMatrix() const { return m_projectionMatrix; }
int GetWidth() const { return m_width; }
int GetHeight() const { return m_height; }
void SetAmbientLightColor(const Vector4&
ambientLightColor)
{
m_ambientLightColor = ambientLightColor;
}
const Vector4&
GetAmbientLightColor() const
{
return m_ambientLightColor;
}
};
图 9-2 显示了游戏的状态,环境光照被应用到渲染中。
图 9-2 。环境照明场景
如图 9-2 所示,每种颜色成分的环境光水平为 0.2f 意味着我们的物体几乎是黑色的。几乎不可能辨认出 AI 敌人身上的任何红色阴影,但是玩家确实有一点绿色阴影。板条箱也是非常浅的蓝色。这对于我们的场景来说非常理想,当我们添加更多的颜色时,我们会看到这一点。
在我们进入照明等式的下一个元素之前,我们必须更新我们的几何图形。光照方程的漫反射和镜面反射部分需要知道模型中多边形的朝向。我们可以通过在着色器中提供一个法向量和每个顶点来做到这一点。
顶点法线
到目前为止,我们已经在这本书里碰到过几次法向量。如果你还记得,法向量是一个单位长度的向量,用来表示方向,而不是表示位移。
我们可以用平面方程算出多边形的法线。由于我们在为 Android 开发 OpenGL ES 2.0 游戏时总是在处理平面三角形,所以我们可以使用三角形的三个点来生成三角形的曲面法线。附录 D 中介绍了这个过程的数学,以及Plane
类及其方法的列表。
幸运的是,我们所有的模型通常都是从 3D 建模包中导出的。这些 3D 建模软件包通常能够为我们正在创建的任何模型生成和导出表面法线。对于这本书,我一直使用免费的建模包 Blender,你可以从www.blender.org
获得。
假设我们将从 3D 包中导出网格的顶点法线,我们应该看看如何在代码中表示这些数据。清单 9-8 显示了Geometry
类中支持顶点法线所需的更新。
清单 9-8。 更新Geometry
来处理顶点法线。Geometry.h
class Geometry
{
private:
static const unsigned int NAME_MAX_LENGTH = 16;
char m_name[NAME_MAX_LENGTH];
int m_numVertices;
int m_numIndices;
void* m_pVertices;
void* m_pIndices;
int m_numVertexPositionElements;
int m_numNormalPositionElements;
int m_numTexCoordElements;
int m_vertexStride;
public:
Geometry();
virtual ∼Geometry();
void SetName(const char* name) { strcpy(m_name, name); }
void SetNumVertices(const int numVertices) { m_numVertices = numVertices; }
void SetNumIndices(const int numIndices) { m_numIndices = numIndices; }
const char* GetName() const { return m_name; }
const int GetNumVertices() const { return m_numVertices; }
const int GetNumIndices() const { return m_numIndices; }
void* GetVertexBuffer() const { return m_pVertices; }
void* GetIndexBuffer() const { return m_pIndices; }
void SetVertexBuffer(void* pVertices) { m_pVertices = pVertices; }
void SetIndexBuffer(void* pIndices) { m_pIndices = pIndices; }
void SetNumVertexPositionElements(const int numVertexPositionElements)
{
m_numVertexPositionElements = numVertexPositionElements;
}
int GetNumVertexPositionElements() const
{
return m_numVertexPositionElements;
}
void SetNumNormalPositionElements(const int numNormalPositionElements)
{
m_numNormalPositionElements = numNormalPositionElements;
}
int GetNumNormalPositionElements() const
{
return m_numNormalPositionElements;
}
void SetNumTexCoordElements(const int numTexCoordElements)
{
m_numTexCoordElements = numTexCoordElements;
}
int GetNumTexCoordElements() const
{
return m_numTexCoordElements;
}
void SetVertexStride(const int vertexStride)
{
m_vertexStride = vertexStride;
}
int GetVertexStride() const
{
return m_vertexStride;
}
};
我们添加了字段来存储我们的Geometry
类的法线数量。这进一步扩展了我们的结构数组格式的顶点数据的存储。这是将几何数据流式传输到当前移动 GPU 的最佳方法。
随着Geometry
类现在能够处理包含顶点法线数据的模型,让我们继续看看漫射光照着色器将如何利用它们。
漫射照明
我们试图用本章中的着色器实现的光照方程 是一个加法方程。这意味着我们的照明组件加在一起,形成最终的结果。在漫射照明这一节中,我们将看看下面等式的第二部分:
最终颜色=环境颜色+漫射颜色+镜面颜色
我们已经看到了环境颜色是如何设置物体的基础光值的。然而,环境光本身仍然让我们的物体看起来是平的。这是因为环境光功能没有考虑光源和表面朝向的方向之间的角度。
现在,我们将实现一个游戏光源的最简单的版本,一个方向灯。平行光 用于模拟极远处的光源。如果你想象太阳,我们可以通过把它想象成一个在各个方向上强度相等的球体来简化它所发出的光。当来自太阳的光到达地球时,来自球体的光线来自整个球体的一个非常小的碎片。在一个游戏场景中,我们将其简化为一个模型,在这个模型中,我们认为来自这个光源的所有光线都是平行传播的,并且从完全相同的方向照射到我们所有的物体上。
“方向”和“定向”这两个词在上一段中已经使用了几次,你可能已经猜到我们将使用另一个法向量来表示光线的方向。然而,我们将不会存储光传播的方向;我们实际上会存储相反的内容。当你看到我们的颜色的漫射照明分量的等式时,这个原因就变得很清楚了:
漫射颜色= max(L.N,0) ×漫射灯光颜色×漫射材质颜色
前面等式中的 L.N 项表示我们的方向光矢量和当前顶点法线之间的点积。点积给出了以下结果:
L.N = |L||N|cos(alpha)
L 和 N 周围的线代表这些向量的长度(或大小)。光照方程中的向量是法线;因此,它们的长度是 1。这意味着两个法向量点积的结果是两者夹角的余弦。0 度的余弦为 1,90 度的余弦为 0,180 度的余弦为 1。由于我们的片段颜色值输出范围是从 0 到 1,我们将使用点积结果或 0 中的较高值。对于 0 到 90 度之间的任何角度,我们将为这个片段添加一个漫反射颜色组件。
漫射组件顶点着色器
在我们查看漫反射组件的片段着色器代码之前,我们将检查设置顶点位置和法线所需的顶点着色器。清单 9-9 包含了TransformAmbientDiffuseShader
的顶点着色器的代码。
清单 9-9。 TransformAmbientDiffuseShader's
顶点着色器来源。transformationdiffuse shader . CPP
m_vertexShaderCode =
"uniform mat4 u_mModelViewProj; \n"
"uniform mat3 u_mModelIT; \n"
"attribute vec4 a_vPosition; \n"
"attribute vec3 a_vNormal; \n"
"varying vec3 v_vNormal; \n"
"void main(){ \n"
" gl_Position = u_mModelViewProj * a_vPosition; \n"
" v_vNormal = normalize(u_mModelIT * a_vNormal); \n"
"} \n";
我们的顶点着色器易于阅读。我们有一个矩阵u_mModelViewProj
,像往常一样,我们用它将顶点的位置属性转换成标准化的设备坐标。
我们现在还有一个顶点法线属性和一个可变变量来存储输出。GLSL 的变量用于在组成三角形的三个顶点之间插值。因为我们现在为每个顶点指定了一个法向量,所以我们必须将每个顶点存储到一个变量中,以便为每个要着色的片段进行插值。
当我们将法线存入v_vNormal
时,我们也将它乘以矩阵u_mModelIT
。这个矩阵负责将矩阵从模型的局部空间转换到世界空间。由于法向量不需要任何平移,矩阵本身只是一个 3x3 的旋转和缩放矩阵。不幸的是,我们不能简单地用模型的变换矩阵直接变换法线。可以应用于模型的任何缩放将导致法线相对于其表面改变方向。相反,我们必须使用模型矩阵的逆转置来转换法线。
如果你还记得,旋转矩阵是一个正交矩阵。这种矩阵的特殊之处在于它的逆矩阵也是它的转置矩阵;因此,模型变换的旋转部分的逆转置将保持不变。缩放矩阵是对角矩阵,因此缩放矩阵的转置与标准矩阵没有什么不同。逆标度元素包含 1 除以原始标度,得到逆。将法线与模型矩阵的逆转置相乘允许我们以与模型相同的方式将法线旋转到世界空间中,但也保留了原始法线相对于它所代表的表面的方向。
漫射组件片段着色器
随着顶点着色器的方式,我们可以看看片段着色器。我们在清单 9-10 中这样做。
清单 9-10。 TransformAmbientDiffuseShader's
碎片着色器来源。transformationdiffuse shader . CPP
m_fragmentShaderCode =
"precision mediump float; \n"
"varying vec3 v_vNormal; \n"
" \n"
"uniform vec4 u_vAmbientColor; \n"
"uniform vec4 u_vDiffuseColor; \n"
"uniform vec4 u_vAmbientLight; \n"
"uniform vec4 u_vDiffuseLight; \n"
"uniform vec3 u_vLightDirection; \n"
" \n"
"const float c_zero = 0.0; \n"
"const float c_one = 1.0; \n"
" \n"
"void main(){ \n"
" gl_FragColor = vec4(c_zero, c_zero, c_zero, c_zero); \n"
" \n"
" float ndotl = dot(u_vLightDirection, v_vNormal); \n"
" ndotl = max(ndotl, c_zero); \n"
" gl_FragColor += ndotl * u_vDiffuseLight * u_vDiffuseColor; \n"
" \n"
" gl_FragColor += u_vAmbientLight * u_vAmbientColor; \n"
" \n"
" gl_FragColor.a = c_one; \n"
"} \n";
漫射照明组件的片段着色器首先声明该着色器程序的浮点运算的默认精度。在研究具体细节时,着色器精度限定符可能是一个复杂的主题。出于本书的目的,知道精度影响给定数据类型的可用值范围就足够了。
有三种精度限定符可用,lowp
、mediump
和highp
。出于照明等式的目的,lowp
通常不能提供足够的精度,而highp
通常能提供比我们需要的更多的精度。每一级精度的提高导致着色器执行时间更长;因此,选择适合任何给定着色器的精度级别是很重要的。我这里用了mediump
;然而,当我将设置更改为lowp
时,我实际上看不出有什么不同。
同样值得记住的是,有些平台可能不支持所有的精度限定符。OpenGL ES 2.0 标准规定片段着色器和顶点着色器所需的最低精度限定符分别为mediump
和 highp。
注意目前,Nvidia 的 Tegra 3 平台是唯一不支持片段着色器中的highp
限定符的芯片组。然而,如果你确实使用了highp
,Tegra 3 着色器编译器将自动使用mediump
,但是值得记住这一点。
接下来,我们声明包含插值法向量的变量。请记住,顶点着色器将为每个顶点计算转换后的法线,GPU 将使用线性插值来计算每个片段的法线位置。线性插值通过在每个极端使用值 0 和 1 来计算。数字 5 和 10 中间的线性插值看起来像下面的等式。
((10–5) 0.5)+5 = 7.5*
在这里,我们计算两个极端值之间的差值,在这种情况下,该差值被总和 10–5 覆盖。然后,我们将该范围乘以插值因子,插值因子为 0.5,代表两个极值之间的中间值。最后一步包括添加第一个极值来计算位于第一个和第二个点之间的点。
然后我们有了统一的价值观。制服是从游戏代码提供给片段着色器的所有实例的变量。在我们的漫反射着色器中,我们提供了代表物体材质的环境光和漫反射颜色、灯光的环境光和漫反射颜色以及灯光方向的制服。我们还指定了常数来表示值 0.0 和 1.0;分别是c_zero
和c_one
。
我们的 main 方法是在声明了所有变量之后定义的。我们首先将gl_FragColor
初始化为在每个元素包含c_zero
的vec4
。
然后使用点方法计算矢量v_vLightDirection
和v_vNormal
的点积。通过在我们的片段着色器中进行这种计算,我们已经实现了被称为逐像素照明的技术。如果我们已经在顶点着色器中计算了点积,我们将实现逐顶点照明。在顶点着色器中计算光照方程要快得多,但结果并不理想。如果你正在实现一个完整的游戏,用逐片段着色器照亮关键对象,用逐顶点着色器照亮其他不太重要的对象可能是一种用来优化游戏的技术。
着色器中的下一行使用 max 将点积的最低可能值限制为 0。然后,我们将计算漫反射颜色所需的三个元素相乘,ndotl
、u_vDiffuseLight
和u_vDiffuseColor
。
随着我们的漫射颜色分量的计算,我们然后添加环境分量的结果。其计算方法与清单 9-4 中的相同,将环境光向量乘以环境颜色向量。
使用 OpenGL ES 2.0 初始化着色器
清单 9-11 包含了获取我们制服和属性的句柄所需的Link
方法 。
清单 9-11。 TransformAmbientDiffuseShader::Link
。transformambientdevissueshader . CPP
void TransformAmbientDiffuseShader::Link()
{
Shader::Link();
m_modelViewProjUniformHandle = glGetUniformLocation(m_programId, "u_mModelViewProj");
m_modelITMatrixUniformHandle = glGetUniformLocation(m_programId, "u_mModelIT");
m_positionAttributeHandle = glGetAttribLocation(m_programId, "a_vPosition");
m_normalAttributeHandle = glGetAttribLocation(m_programId, "a_vNormal");
m_ambientColorUniformHandle = glGetUniformLocation(m_programId, "u_vAmbientColor");
m_diffuseColorUniformHandle = glGetUniformLocation(m_programId, "u_vDiffuseColor");
m_ambientLightUniformHandle = glGetUniformLocation(m_programId, "u_vAmbientLight");
m_diffuseLightUniformHandle = glGetUniformLocation(m_programId, "u_vDiffuseLight");
m_lightDirectionUniformHandle = glGetUniformLocation(m_programId, "u_vLightDirection");
}
回想一下清单 9-9 中的内容,我们必须向顶点着色器提供模型的逆转置变换矩阵来变换顶点法线。清单 9-12 显示了TransformAmbientDiffuseShader::Setup
方法,它包含了计算这个矩阵的代码。
清单 9-12。 TransformAmbientDiffuseShader::Setup
。transformambientdevissueshader . CPP
void TransformAmbientDiffuseShader::Setup(Renderable& renderable)
{
Geometry* pGeometry = renderable.GetGeometry();
if (pGeometry)
{
Shader::Setup(renderable);
Renderer& renderer = Renderer::GetSingleton();
const Matrix4& viewMatrix = renderer.GetViewMatrix();
const Matrix4& projectionMatrix = renderer.GetProjectionMatrix();
const Matrix4& modelMatrix = renderable.GetTransform().GetMatrix();
Matrix4 modelViewMatrix;
modelMatrix.Multiply(viewMatrix, modelViewMatrix);
Matrix4 modelViewProjectionMatrix;
modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);
glUniformMatrix4fv(
m_modelViewProjUniformHandle,
1,
false,
modelViewProjectionMatrix.m_m);
Matrix3 modelIT;
renderable.GetTransform().GetInverseTransposeMatrix(modelIT);
glUniformMatrix3fv(m_modelITMatrixUniformHandle, 1, false, modelIT.m_m);
glVertexAttribPointer(
m_positionAttributeHandle,
pGeometry->GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
pGeometry->GetVertexStride(),
pGeometry->GetVertexBuffer());
glEnableVertexAttribArray(m_positionAttributeHandle);
glVertexAttribPointer(
m_normalAttributeHandle,
pGeometry->GetNumNormalPositionElements(),
GL_FLOAT,
GL_FALSE,
pGeometry->GetVertexStride(),
static_cast<float*>(pGeometry->GetVertexBuffer()) +
pGeometry->GetNumVertexPositionElements());
glEnableVertexAttribArray(m_normalAttributeHandle);
const Vector4& ambientColor = renderable.GetMaterial()->GetAmbientColor();
glUniform4f(
m_ambientColorUniformHandle,
ambientColor.m_x,
ambientColor.m_y,
ambientColor.m_z,
ambientColor.m_w);
const Vector4& diffuseColor = renderable.GetMaterial()->GetDiffuseColor();
glUniform4f(
m_diffuseColorUniformHandle,
diffuseColor.m_x,
diffuseColor.m_y,
diffuseColor.m_z,
diffuseColor.m_w);
const Vector4& ambientLightColor = renderer.GetAmbientLightColor();
glUniform4f(
m_ambientLightUniformHandle,
ambientLightColor.m_x,
ambientLightColor.m_y,
ambientLightColor.m_z,
ambientLightColor.m_w);
const Vector4& diffuseLightColor = renderer.GetDiffuseLightColor();
glUniform4f(
m_diffuseLightUniformHandle,
diffuseLightColor.m_x,
diffuseLightColor.m_y,
diffuseLightColor.m_z,
diffuseLightColor.m_w);
const Vector3& lightDirection = renderer.GetLightDirection();
glUniform3f(
m_lightDirectionUniformHandle,
lightDirection.m_x,
lightDirection.m_y,
lightDirection.m_z);
}
}
我们通过获得对当前视图矩阵 、投影矩阵 和模型矩阵 的引用来开始该方法。然后将modelMatrix
乘以viewMatrix
得到modelViewMatrix
。modelViewMatrix
再乘以projectionMatrix
。这给了我们modelViewProjection
矩阵,这是将我们的模型顶点转换成标准视图体所必需的。我们使用glUniformMatrix4fv
将这个矩阵上传到 GPU,与我们的顶点着色器中的统一u_mModelViewProj
一起使用。
该方法的下一步是获得模型的变换矩阵的逆转置。我们使用Transform::GetInverseTransposeMatrix
来做这件事。清单 6-21 显示了Transform
的类声明;我们在清单 9-13 中描述了GetInverseTransposeMatrix
?? 的代码。
清单 9-13。 Transform::GetInverseTransposeMatrix
。Transform.cpp
void Transform::GetInverseTransposeMatrix(Matrix4& out) const
{
float invScale = 1.0f / m_scale;
out.m_m[0] = m_rotation.m_m[0] * invScale;
out.m_m[1] = m_rotation.m_m[1];
out.m_m[2] = m_rotation.m_m[2];
out.m_m[3] = 0.0f;
out.m_m[4] = m_rotation.m_m[3];
out.m_m[5] = m_rotation.m_m[4] * invScale;
out.m_m[6] = m_rotation.m_m[5];
out.m_m[7] = 0.0f;
out.m_m[8] = m_rotation.m_m[6];
out.m_m[9] = m_rotation.m_m[7];
out.m_m[10] = m_rotation.m_m[8] * invScale;
out.m_m[11] = 0.0f;
out.m_m[12] = -m_translation.m_x;
out.m_m[13] = -m_translation.m_y;
out.m_m[14] = -m_translation.m_z;
out.m_m[15] = 1.0f;
}
void Transform::GetInverseTransposeMatrix(Matrix3& out) const
{
float invScale = 1.0f / m_scale;
out.m_m[0] = m_rotation.m_m[0] * invScale;
out.m_m[1] = m_rotation.m_m[1];
out.m_m[2] = m_rotation.m_m[2];
out.m_m[3] = m_rotation.m_m[3];
out.m_m[4] = m_rotation.m_m[4] * invScale;
out.m_m[5] = m_rotation.m_m[5];
out.m_m[6] = m_rotation.m_m[6];
out.m_m[7] = m_rotation.m_m[7];
out.m_m[8] = m_rotation.m_m[8] * invScale;
}
清单 9-13 包含了从Transform
获得逆转置矩阵的方法的两个版本。我们已经知道,旋转矩阵的逆转置是原矩阵,所以我们按照正常顺序复制旋转矩阵。标度矩阵的转置不会改变任何东西,我们可以通过将标度分成 1 来非常容易地计算标度分量的倒数。我们在清单 9-12 中的代码使用了这个方法的版本,它获得了一个 3x3 的矩阵,因为我们的法线不需要平移组件。
然后我们的顶点属性就设置好了。用几何类的适当参数初始化m_positionAttributeHandle
,并用glEnableVertexAttribArray
启用。然后我们对法线做同样的事情。第一法线的地址通过将顶点缓冲区指针转换为浮点指针并加上顶点位置元素的数量来计算。
然后使用glUniform4f
和glUniform3f
初始化包含材质和光色属性的矢量。
我们的代码现在应该完成了,我们将在游戏中看到一些漫射灯光。这是我们第一次能够真正看到场景的深度,并且能够告诉我们已经成功地创建了一个三维游戏。图 9-3 显示了启用漫射照明的场景截图。
图 9-3 。漫射照明
从前面的截图可以明显看出,我们已经将漫射光源设置在物体的右上方。灯光在这些区域最亮,在对象的左下方变暗。我们还可以看到玩家和 AI 对象的球形形状,以及我们立方体的深度。
Blinn-Phong 照明模型的剩余部分是镜面反射部分。我们接下来看看这个。
镜面照明
照明等式的镜面反射部分决定了给定对象的表观亮度。到目前为止,环境光组件已经给出了一个基本级别的光,以显示对象最暗区域的颜色。漫射组件为对象添加了大部分照明,这有助于确定场景中对象的颜色和形状。现在,镜面反射组件被添加到这些组件中,以使对象看起来或多或少具有反射性。就像漫反射组件一样,镜面反射组件有一个等式,我们将在片段着色器中实现它。那个方程 如下:
高光颜色= max(H.N,0)^S ×高光颜色×高光材质颜色
前面的等式包含了 Blinn 对 Phong 着色模型 的修改。原始模型包含向量 R 而不是 h。R 代表反射光向量,必须为给定模型中的每个顶点计算该向量。向量 H 表示半向量,并且可以为每个模型计算一次:
半矢量=归一化(眼睛矢量+光线矢量)
前面的等式依赖于分别指向相机位置和光源的眼睛矢量和光矢量。
镜面反射方程中的镜面反射指数 S 控制着材质的光泽度。该指数越高,表面越不亮。
我们现在来看看光照公式中这个部分的顶点着色器。
镜面组件顶点着色器
Blinn-Phong 模型的镜面反射部分的顶点着色器如列表 9-14 所示。
清单 9-14。 TransformAmbientDiffuseSpecularShader's
顶点明暗器。transformationdifferential mirror shader . CPP
m_vertexShaderCode =
"uniform mat4 u_mModelViewProj; \n"
"uniform mat3 u_mModelIT; \n"
"attribute vec4 a_vPosition; \n"
"attribute vec3 a_vNormal; \n"
"varying vec3 v_vNormal; \n"
"void main(){ \n"
" gl_Position = u_mModelViewProj * a_vPosition; \n"
" v_vNormal = normalize(u_mModelIT * a_vNormal); \n"
"} \n";
希望我们的顶点着色器与TransformAmbientDiffuseShader
中使用的顶点着色器没有什么不同,这并不奇怪。我们使用 Phong 着色来计算每个像素的光照值。我们将直接进入片段着色器。
镜面组件片段着色器
我们的片段着色器将添加 Blinn-Phong 模型的下一个附加组件,镜面组件。我们已经看过这个组件的方程式,所以清单 9-15 直接进入片段着色器源代码。
清单 9-15。 TransformAmbientDiffuseSpecularShader's
碎片着色器。transformationdifferential mirror shader . CPP
m_fragmentShaderCode =
"precision mediump float; \n"
"varying vec3 v_vNormal; \n"
" \n"
"uniform vec4 u_vAmbientColor; \n"
"uniform vec4 u_vDiffuseColor; \n"
"uniform vec4 u_vSpecularColor; \n"
"uniform float u_fSpecularExponent; \n"
"uniform vec4 u_vAmbientLight; \n"
"uniform vec4 u_vDiffuseLight; \n"
"uniform vec4 u_vSpecularLight; \n"
"uniform vec3 u_vLightDirection; \n"
"uniform vec3 u_vLightHalfVector; \n"
" \n"
"const float c_zero = 0.0; \n"
"const float c_one = 1.0; \n"
" \n"
"void main(){ \n"
" gl_FragColor = vec4(c_zero, c_zero, c_zero, c_zero); \n"
" \n"
" float ndoth = dot(u_vLightHalfVector, v_vNormal); \n"
" ndoth = max(ndoth, c_zero); \n"
" float dotPow = pow(ndoth, u_fSpecularExponent); \n"
" gl_FragColor += dotPow * u_vSpecularColor * u_vSpecularLight; \n"
" \n"
" float ndotl = dot(u_vLightDirection, v_vNormal); \n"
" ndotl = max(ndotl, c_zero); \n"
" gl_FragColor += ndotl * u_vDiffuseLight * u_vDiffuseColor; \n"
" \n"
" gl_FragColor += u_vAmbientLight * u_vAmbientColor; \n"
" \n"
" gl_FragColor.a = c_one; \n"
"} \n";
由于我们的新着色器包含环境和漫射组件,它保留了这些计算所需的所有均匀性。我们新推出的制服是u_vSpecularColor
、u_fSpecularExponent
、u_vSpecularLight
和u_vLightHalfVector
。
着色器中有四条新的线用于计算镜面反射分量。第一个计算u_vLightHalfVector
和v_vNormal
之间的点积。这给了我们半矢量和碎片的法向量之间的角度。然后我们取点积或零的较高值。然后,将前面步骤的结果乘以镜面反射分量的幂,最后,将乘以镜面反射指数的点积乘以镜面反射材质和灯光颜色。
因为这个等式是一个加法过程,我们添加了漫射分量,然后添加了环境分量来得到最终的碎片颜色。
我们的着色器代码完成后,最后一步是查看将着色器投入使用所需的 OpenGL ES 2.0 代码。
正在初始化 transformambientdevisvirsershader
像我们所有的着色器一样,我们必须覆盖Link
和Setup
方法才能使用这个特定的着色器。
我们看看清单 9-16 中的中的TransformAmbientDiffuseSpecularShader::Link
。
清单 9-16。 TransformAmbientDiffuseSpecularShader::Link
。transformambientdevisvirsershader . CPP
void TransformAmbientDiffuseSpecularShader::Link()
{
Shader::Link();
m_modelViewProjUniformHandle = glGetUniformLocation(m_programId, "u_mModelViewProj");
m_modelITMatrixUniformHandle = glGetUniformLocation(m_programId, "u_mModelIT");
m_positionAttributeHandle = glGetAttribLocation(m_programId, "a_vPosition");
m_normalAttributeHandle = glGetAttribLocation(m_programId, "a_vNormal");
m_ambientColorUniformHandle = glGetUniformLocation(m_programId, "u_vAmbientColor");
m_diffuseColorUniformHandle = glGetUniformLocation(m_programId, "u_vDiffuseColor");
m_specularColorUniformHandle = glGetUniformLocation(m_programId, "u_vSpecularColor");
m_specularExponentUniformHandle = glGetUniformLocation(m_programId, "u_fSpecularExponent");
m_ambientLightUniformHandle = glGetUniformLocation(m_programId, "u_vAmbientLight");
m_diffuseLightUniformHandle = glGetUniformLocation(m_programId, "u_vDiffuseLight");
m_specularLightUniformHandle = glGetUniformLocation(m_programId, "u_vSpecularLight");
m_lightDirectionUniformHandle = glGetUniformLocation(m_programId, "u_vLightDirection");
m_lightHalfVectorUniformHandle = glGetUniformLocation(m_programId, "u_vLightHalfVector");
}
在 Link 中,我们使用 OpenGL ES 2.0 方法glGetUniformLocation
和glGetAttribLocation
获得了设置制服和属性所需的所有句柄。我们将这些句柄用于 TransformAmbientDiffuseSpecularShader::Setup
,如清单 9-17 所示。
清单 9-17。 清单 9-17。TransformAmbientDiffuseSpecularShader::Setup
。transformambientdevisvirsershader . CPP
void TransformAmbientDiffuseSpecularShader::Setup(Renderable& renderable)
{
Geometry* pGeometry = renderable.GetGeometry();
if (pGeometry)
{
Shader::Setup(renderable);
Renderer& renderer = Renderer::GetSingleton();
const Matrix4& viewMatrix = renderer.GetViewMatrix();
const Matrix4& projectionMatrix = renderer.GetProjectionMatrix();
const Matrix4& modelMatrix = renderable.GetTransform().GetMatrix();
Matrix4 modelViewMatrix;
modelMatrix.Multiply(viewMatrix, modelViewMatrix);
Matrix4 modelViewProjectionMatrix;
modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);
glUniformMatrix4fv(
m_modelViewProjUniformHandle,
1,
false,
modelViewProjectionMatrix.m_m);
Matrix3 modelIT;
renderable.GetTransform().GetInverseTransposeMatrix(modelIT);
glUniformMatrix3fv(m_modelITMatrixUniformHandle, 1, false, modelIT.m_m);
glVertexAttribPointer(
m_positionAttributeHandle,
pGeometry->GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
pGeometry->GetVertexStride(),
pGeometry->GetVertexBuffer());
glEnableVertexAttribArray(m_positionAttributeHandle);
glVertexAttribPointer(
m_normalAttributeHandle,
pGeometry->GetNumNormalPositionElements(),
GL_FLOAT,
GL_FALSE,
pGeometry->GetVertexStride(),
static_cast<float*>(pGeometry->GetVertexBuffer()) +
pGeometry->GetNumVertexPositionElements());
glEnableVertexAttribArray(m_normalAttributeHandle);
const Vector4& ambientColor = renderable.GetMaterial()->GetAmbientColor();
glUniform4f(
m_ambientColorUniformHandle,
ambientColor.m_x,
ambientColor.m_y,
ambientColor.m_z,
ambientColor.m_w);
const Vector4& diffuseColor = renderable.GetMaterial()->GetDiffuseColor();
glUniform4f(
m_diffuseColorUniformHandle,
diffuseColor.m_x,
diffuseColor.m_y,
diffuseColor.m_z,
diffuseColor.m_w);
const Vector4&specularColor = renderable.GetMaterial()->GetSpecularColor();
glUniform4f(
m_specularColorUniformHandle,
specularColor.m_x,
specularColor.m_y,
specularColor.m_z,
specularColor.m_w);
glUniform1f(
m_specularExponentUniformHandle,
renderable.GetMaterial()->GetSpecularExponent());
const Vector4& ambientLightColor = renderer.GetAmbientLightColor();
glUniform4f(
m_ambientLightUniformHandle,
ambientLightColor.m_x,
ambientLightColor.m_y,
ambientLightColor.m_z,
ambientLightColor.m_w);
const Vector4& diffuseLightColor = renderer.GetDiffuseLightColor();
glUniform4f(
m_diffuseLightUniformHandle,
diffuseLightColor.m_x,
diffuseLightColor.m_y,
diffuseLightColor.m_z,
diffuseLightColor.m_w);
const Vector4&
specularLightColor = renderer.GetSpecularLightColor();
glUniform4f(
m_specularLightUniformHandle,
specularLightColor.m_x,
specularLightColor.m_y,
specularLightColor.m_z,
specularLightColor.m_w);
const Vector3& lightDirection = renderer.GetLightDirection();
glUniform3f(
m_lightDirectionUniformHandle,
lightDirection.m_x,
lightDirection.m_y,
lightDirection.m_z);
Vector3 lightHalfVector = renderer.GetCameraTransform().GetTranslation();
lightHalfVector.Subtract(
Vector3(modelMatrix.m_m[12], modelMatrix.m_m[13], modelMatrix.m_m[14]));
lightHalfVector.Normalize();
lightHalfVector.Add(lightDirection);
lightHalfVector.Normalize();
glUniform3f(
m_lightHalfVectorUniformHandle,
lightHalfVector.m_x,
lightHalfVector.m_y,
lightHalfVector.m_z);
}
}
前面清单中粗体显示的代码行是TransformAmbientDiffuseSpecularShader::Setup
和TransformAmbientDiffuseShader::Setup
之间的不同代码行。
第一个模块负责将物体材质的镜面颜色和指数上传到 GPU。第二个确定的部分将光的镜面颜色上传到 GPU。
最后一部分负责计算光线的半矢量。我们从获取相机物体在世界空间中的位置开始。通过减去模型的位置,我们从模型中获得一个指向摄像机方向的向量。然后我们通过调用Normalize
方法将它转换成单位法线。现在,我们通过将眼睛向量和光线向量相加,找到它们之间的中间向量。一旦我们有了这个向量,我们希望再次成为单位法线,所以我们第二次调用Normalize
。为每个模型计算一次这个向量是 Blinn 对原始 Phong 光照模型的优化。
随着镜面反射组件现在添加到着色器,我们可以看到完整的照明方程的结果。图 9-4 显示了包含最终结果的屏幕截图。
图 9-4 。完整的 Blinn-Phong 照明模型
图 9-5 显示了本章中的每个图。
图 9-5 。一张合成的截图在第九章中见过
从左上角开始,我们有一个没有灯光的场景。颜色完全饱和,场景中的物体看起来都完全平坦。
移动到右上角的瓷砖,我们看到了环境照明场景。虽然第一个单幅图块显示场景没有照明,但实际上它是用环境颜色完全照亮的。一个没有照明的场景实际上会呈现完全的黑色。我们的环境场景添加了一个基本的光照层,我们可以稍微看到玩家对象的绿色阴影。
左下角的图片第一次显示了物体的一些定义。这要归功于漫射照明,它是第一个考虑给定碎片是指向光源还是远离光源的照明组件。
最后一张图片显示了我们的场景,添加了光照方程的镜面反射部分。这给我们的物体表面增加了一点反射性。当漫射照明的物体看起来像固体物体时,它们就有点无趣了。最终图像上的高光使我们的球体看起来更真实。
这个简单的模型足以为我们的游戏增加照明。本章的以下部分将描述一些通过着色器向游戏添加照明和材质的不同选项。
不同类型的灯
我们在本章中开发的这种类型的光叫做平行光。这是一个计算简单的光模型,有利于我们的手机游戏。移动 GPU 目前没有游戏控制台或台式电脑中的强大。毫无疑问,未来几年,手机和平板电脑中 GPU 的处理能力将大幅提高,但目前,针对这些设备的游戏不能在着色器中使用过于复杂的照明模型。
本节将讨论另外两种类型的灯,随着移动 GPU 变得更加强大,它们可能会有更大的用途。
正向阴影
在正向着色中,使用一个单一过程,在该过程中,执行创建最终图像的所有计算。我们在本章中实现的平行光是属于前向阴影类别的光的例子。
这种方法的主要缺点是,它需要大量的计算来为每个渲染对象计算来自多个光源的照明分量。每个光源的计算成本限制了任何给定场景中活动灯光的数量,许多游戏求助于预先计算它们的照明,并将其包含在应用于游戏关卡本身的纹理中,或者通过更改关卡中使用的纹理,或者通过实现光照贴图。特别是在现代移动图形硬件上,其优势在于使用了单个目标渲染图面,因此在运行多个渲染过程时,不存在与切换渲染目标相关联的成本。
在正向明暗处理中有用的其他类型的光源是点光源和聚光灯。
点光源
点光源不同于我们在着色器中描述的平行光,因为它在世界中有一个位置。这意味着灯光可以放在世界内部,从不同的方向照亮物体。
点光源通过从光源位置减去顶点位置来计算每个顶点的光源方向,从而影响所有对象。这样做可以为光源创建一个球形。
点光源通常也用衰减算法实现。光的衰减使得物体离光越远,光对物体的影响就越小。平行光用于模拟无限远的光,例如简单的阳光模型,因此衰减系数对它们来说没有什么意义。
光矢量和衰减因子的计算增加了顶点着色器的计算复杂度。如果你的游戏非常简单,这种类型的灯光可能是可行的,但在大多数游戏中,需要多种这种类型的灯光来创建想要的效果。对于具有一些几何复杂性的场景,这通常使这种技术超出了大多数低端移动 GPU 的能力。
聚光灯
聚光灯是另一种比点光源更复杂的光源。聚光灯有位置和方向。当使用聚光灯时,它们类似于手电筒的效果。聚光灯像点光源一样包含衰减因子,但它们也有聚光灯截止参数。
此截止参数定义聚光灯圆锥的半角。这决定了聚光灯有多宽。由方向和角度创建的圆锥体之外的任何东西都不会被聚光灯照亮。聚光灯还有另一个衰减系数,它决定了从圆锥体中心到侧面的光的亮度。这使得聚光灯在中间更亮,并向圆锥体的边缘逐渐减弱。
点光源和聚光灯是 OpenGL ES 1.0 提供的光源类型,它们以传统方式实现被称为前向着色。在现代游戏中,一种用于合成场景的现代技术被称为延迟渲染。
延期渲染
在 Xbox 360 和 Playstation 3 上的许多游戏中使用的另一种技术是延迟渲染。
延迟渲染通常分两步实现。第一遍渲染到几个缓冲区。对象的漫反射颜色存储在漫反射颜色 G 缓冲区中。这不同于我们在 Blinn-Phong 模型中实现的漫射颜色。存储的颜色只是对象的单一漫反射材质颜色。延迟渲染中不使用环境光,任何镜面反射照明都是稍后计算的。除了扩散 G 缓冲区,普通 G 缓冲区存储每个片段的法线向量,z 缓冲区存储每个片段在每个位置的深度。
然后,第二遍渲染每个灯光的几何形状。使用灯光信息以及从第一遍写入的缓冲区读取的颜色、法线和深度,在每个位置计算照明方程式。
这种模型的好处是,在一个场景中可以渲染数百个灯光,因为它们只针对帧中的像素进行计算,这些像素实际上会成为最终的图像。缺点是它不能处理透明物体,所以你仍然需要一个传统的渲染过程。
可以用着色器实现的另一个高级主题是不同类型的材质。这是通过在着色器中使用不同的照明方程式来完成的。这些方程被称为双向反射分布函数(BRDFs)。
双向反射分布函数
BRDFs 是一组描述计算反射向量的方法的方程。我们在本章中实现的 Blinn-Phong 方程只是这组方程中的一个例子。
Blinn-Phong 模型非常适合于近似具有类似于塑料外观的材质。固定功能图形管道(如 OpenGL ES 1.0 中的图形管道)仅向游戏开发人员展示了这种模型,但借助于着色器,OpenGL ES 2.0 程序员可以自由实现更多类型的 BRDFs。
整本书都是以计算机图形学中的光照为主题的,如果你有兴趣了解更多关于 BRDFs 的知识,可以从托兰斯-斯派洛模型、柳文欢-纳耶漫反射模型和沃德反射模型开始。
这些不同的照明方程更适合于再现不同材质的外观,如金属、纸张、天鹅绒、沙子和木材。
摘要
在这一章中,我们终于给场景增加了深度感。我们通过添加新的着色器程序实现了这一点。
我们已经知道在游戏中使用平行光来创建一个简单的光源模型,它离我们的游戏对象很远。定向光源的一个真实例子是太阳。我们都知道太阳不发射平行光线,这表明我们可以使用简化来模拟现实世界现象的简单模型。这种简化物理世界的能力对于从事实时游戏的游戏程序员来说是一项关键技能,因为实时游戏依赖于在 33 毫秒或更短的时间内完成整个帧的计算。
Blinn-Phong 着色模型已在 GLSL 着色器中涵盖和实现。本章附带的示例代码包含本章中描述的所有着色器阶段的实现。您可以在它们之间切换,以查看每个照明组件的行为,并且可以改变材质和灯光颜色以及镜面反射指数,以感受该照明模型在不同参数下的行为。
你现在也应该知道,Blinn-Phong 模型由三个不同的组件组成,它们构成了片段着色器中的最终颜色。环境颜色是应用于对象的灯光的基本级别。漫反射颜色决定了对象颜色的主要方面,并根据顶点法线和灯光向量之间的角度考虑了反射光的强度。最后一个部分是镜面反射部分。该组件为曲面添加了一个反射元素,也是 Phong 模型的组件,Blinn 采用该组件创建了 Blinn-Phong 模型。
最后,我们讲述了照明和材质领域的更多细节。虽然这是一个初级水平的文本,但重要的是要知道游戏引擎的照明和材质属性构成了一个深刻而非常有趣的主题。顶点和片段着色器为游戏开发人员提供了照片级材质渲染的可能性,随着 GPU 能够实现越来越复杂的照明模型,着色器编程将成为一个不断发展的有趣主题。
在下一章,我们将从玩家的视觉转移到他们的听觉。音频是现代视频游戏的一个非常重要的方面,虽然音频曾经是开发过程中被忽视的一部分,但现在不再是这样了。成功的游戏在制作的早期就计划好它们的音频,音频设计师和工程师被雇佣来扮演这些特殊的角色。