五、编写渲染器

游戏引擎执行的关键任务之一是将几何数据馈送到图形处理单元(GPU) 。GPU 是一种高度专业化的硬件,可以并行处理数据流。这种处理的并行化使得 GPU 在现代实时图形应用中至关重要,也是它们被称为硬件加速器的原因。

渲染器的工作是尽可能高效地将几何图形提供给 GPU。在一个游戏引擎中,在处理游戏更新,也就是移动游戏对象,人工智能,物理等方面会有明显的不同。,并渲染场景。

编写一个在 CPU 上运行的软件渲染器是完全可能的,但是在 CPU 处理能力非常宝贵的手机上,这将是一个徒劳的任务。最好的解决方案是使用 GPU。在这一章,我们将看一个基本的渲染器。

使用 EGL 初始化窗口和 OpenGL

这个标题混合了一些新程序员可能不熟悉的首字母缩写词和概念。在过去的几十年里,操作系统一直使用基于窗口的系统,大多数人都知道窗口的概念。然而,您可能会惊讶地看到与移动操作系统相关的窗口概念,移动操作系统通常没有可重新定位的窗口。Android 仍然使用一个窗口系统来描述我们用来访问设备屏幕的抽象对象。

OpenGL 是一个图形库,自 1992 年就已经存在。OpenGL 最初是由 SGI 开发的,目前由 Khronos Group 维护。它是主要用于游戏开发的两个主要图形 API 之一。另一个是 DirectX,由微软开发,是基于 Windows 的操作系统的专属。因此,很长一段时间以来,OpenGL 一直是 Linux 和基于移动设备的操作系统的首选 API。

EGL (嵌入式系统图形库)是由 Khronos 提供的一个库,Khronos 是一个非营利性联盟,控制着几个行业标准 API,如 OpenGL、OpenCL 和许多其他 API。EGL 是一个接口 API,它为开发人员提供了一种在操作系统的窗口体系结构和 OpenGL API 之间进行通信的简单方法。这个库允许我们只用几行代码来初始化和使用 OpenGL,任何在十年或更久以前使用图形 API 开发应用的人都会欣赏它的简洁。

为了开始我们的渲染器,我们将创建一个名为Renderer的新类,它将继承我们在前一章创建的Task类。清单 5-1 中的类定义显示了Renderer接口。

清单 6-1。 渲染器类

class Renderer
       :      public Task
{
private:
       android_app*         m_pState;
       EGLDisplay           m_display;
       EGLContext           m_context;
       EGLSurface           m_surface;
       int                  m_width;
       int                  m_height;
       bool                 m_initialized;

public:
       explicit Renderer(android_app* pState, const unsigned int priority);
       virtual Renderer();

       void Init();
       void Destroy();

       // From Task
       virtual bool         Start();
       virtual void         OnSuspend();
       virtual void         Update();
       virtual void         OnResume();
       virtual void         Stop();

       bool IsInitialized() { return m_initialized; }
};

通常的Task方法已经就位:我们现在来看看这些方法(参见清单 5-2 )。

清单 6-2。 渲染器的被覆盖任务方法

bool Renderer::Start()
{
       return true;
}

void Renderer::OnSuspend()
{

}

void Renderer::Update()
{

}

void Renderer::OnResume()
{

}

void Renderer::Stop()
{

}

目前,Renderer类没有做太多事情。我们将在阅读本章时填写它。下一个感兴趣的方法是Init(见清单 5-3 )。

清单 6-3。 使用 EGL 初始化 OpenGL

void Renderer::Init()
{
       // initialize OpenGL ES and EGL

       /* Here, specify the attributes of the desired configuration. In the following code, we select an EGLConfig with at least eight bits per color component, compatible with on-screen windows. */
       const EGLint attribs[] =
       {
              EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
              EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
              EGL_BLUE_SIZE, 8,
              EGL_GREEN_SIZE, 8,
              EGL_RED_SIZE, 8,
              EGL_NONE
       };

       EGLint        format;
       EGLint        numConfigs;
       EGLConfig     config;

       m_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);

       eglInitialize(m_display, NULL, NULL);

       /* Here, the application chooses the configuration it desires. In this sample, we have a very simplified selection process, where we pick the first EGLConfig that matches our criteria. */
       eglChooseConfig(m_display, attribs, &config, 1, &numConfigs);

       /* EGL_NATIVE_VISUAL_ID is an attribute of the EGLConfig that is guaranteed to be accepted by ANativeWindow_setBuffersGeometry(). As soon as we pick a EGLConfig, we can safely reconfigure the ANativeWindow buffers to match, using EGL_NATIVE_VISUAL_ID. */
       eglGetConfigAttrib(m_display, config, EGL_NATIVE_VISUAL_ID, &format);

       ANativeWindow_setBuffersGeometry(m_pState->window, 0, 0, format);

       m_surface = eglCreateWindowSurface(m_display, config, m_pState->window, NULL);

       EGLint contextAttribs[] =
       {
              EGL_CONTEXT_CLIENT_VERSION, 2,
              EGL_NONE
       };
       m_context = eglCreateContext(m_display, config, NULL, contextAttribs);

       eglMakeCurrent(m_display, m_surface, m_surface, m_context);

       eglQuerySurface(m_display, m_surface, EGL_WIDTH, &m_width);
       eglQuerySurface(m_display, m_surface, EGL_HEIGHT, &m_height);

       m_initialized = true;
}

这段代码实际上是示例应用中提供的代码的副本。有许多其他事情可以通过不同的配置和设置组合来实现,其中一些比我们现在想要的更高级,所以我们将坚持这个基本设置,直到我们启动并运行。

快速浏览一下清单 5-3 ,你可以看到我们正在使用 OpenGL ES 2.0 建立一个渲染表面,它可以存储红色、绿色和蓝色的 8 位值。

然后,我们通过对eglInitializeeglChooseConfigeglGetConfigAttrib的后续调用来设置 EGL(EGL 文档可以在www.khronos.org/registry/egl/找到)。通过这些方法获得的信息然后被用来告诉 Android 操作系统我们希望如何配置窗口来显示我们的游戏。最后但同样重要的是,我们用 EGL 将显示、表面和上下文设置为当前对象,并获得屏幕的宽度和高度。

我们在屏幕上绘制图形所需的一切都已经设置好了,并且正在工作。下一步是看看如何正确地清理我们自己(见清单 5-4 )。

清单 6-4。 破坏 OpenGL

void Renderer::Destroy()
{
       m_initialized = false;

       if (m_display != EGL_NO_DISPLAY)
       {
              eglMakeCurrent(m_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
              if (m_context != EGL_NO_CONTEXT)
              {
                     eglDestroyContext(m_display, m_context);
              }
              if (m_surface != EGL_NO_SURFACE)
              {
                     eglDestroySurface(m_display, m_surface);
              }
              eglTerminate(m_display);
       }
       m_display = EGL_NO_DISPLAY;
       m_context = EGL_NO_CONTEXT;
       m_surface = EGL_NO_SURFACE;
}

清单 5-4 可以看出,拆掉 OpenGL 是一个很容易的过程。这对于及时将资源交还给操作系统是必要的,也是为了确保我们的游戏在用户重新开始游戏时有最好的机会恢复。通过为当前显示设置不存在的表面和上下文,我们可以确保没有其他资源可以在以后成功请求使用它们并导致问题。然后我们也破坏环境和表面来释放他们的资源。最后,我们终止 EGL 以完成关闭。简单、直接,是创建良好应用的良好开端。

随着我们的渲染器设置完毕并准备就绪,现在是时候来看看可编程 GPU 如何使用顶点和片段着色器了。

着色器简介

当消费类硬件 3D 加速器在 20 世纪 90 年代中期首次出现时,它们包含固定功能的管道。这意味着每个加速器都以完全相同的方式运行,因为它们执行的算法被内置在为这些特定目的而创建的芯片中。

所有厂商的第一代卡都进行了多边形的硬件加速光栅化;例如,获取纹理并将其应用于多边形的算法是这些卡执行的第一个特定任务。

此时,顶点仍在 CPU 上的软件中被转换和点亮。第一款实现硬件转换和照明的消费级显卡是 Nvidea GeForce 256。这种从软件到硬件加速顶点处理的转变采用率很低,因为驱动程序和 API 需要相当长的时间才能赶上硬件。最终,硬件 T & L 被更广泛地采用,这导致了除英伟达和 ATI 之外的几乎所有 GPU 制造商的灭亡,他们迅速转向生产支持 T & L 的廉价卡,其性能优于没有这种硬件的昂贵得多的卡。

随着 GeForce 3 的发布,消费 GPU 硬件的下一个重大转变再次来自 Nvidia。发布于 2001 年,它是第一个包含可编程像素和顶点着色器的 GPU。它恰逢 DirectX 8.0 API 的发布,该 API 包括对使用汇编语言编写着色器的支持。

OpenGL 中的汇编语言着色器支持通过 OpenGL 1.5 中的扩展添加。直到 2004 年 OpenGL 2.0 的发布,才出现完整的着色器支持。一个主要的范式转变也发生在这个时候,汇编语言着色器被 OpenGL 着色语言,GLSL 所取代。着色器编程语言的引入向更多人开放了该功能集,因为该语言比汇编编程更直观。

这段历史将我们带到了现代的 Android。移动图形处理器通常被设计为在小型电池供电设备上运行,因此放弃了桌面系统中的一些功能来延长电池寿命。这导致了 OpenGL 的移动专用版本的开发,称为嵌入式系统 OpenGL(OpenGL ES)。OpenGL ES 1.0 版不支持顶点和像素着色器;然而,这些是随着 OpenGL ES 2.0 的发布而引入的,OpenGL ES 2.0 被集成到 Android 操作系统中,并从 2.0 版本开始通过 SDK 提供。

对于这本书,我决定只看 OpenGL ES 2.0,因为以前的版本正在被淘汰,只有很少的新设备支持这个版本的 API。这意味着我们需要了解如何在游戏中编写和使用顶点和像素着色器。下一节将向我们介绍顶点着色器。

OpenGL ES 2.0 中的顶点着色器介绍

顶点着色器的目的是将顶点从它们的局部空间(它们在建模包中建模的空间)转换到规范视图体中。该体积是一个从 1,1,1 到–1,–1,–1 的立方体,有必要在管道的下一步将我们的顶点放入该立方体,以确保它们不会在片段着色阶段之前被 GPU 丢弃。在图 5-1 中可以看到管道。裁剪阶段移除将不被渲染的多边形部分,以防止这些片段被发送通过昂贵的光栅化阶段。

9781430258308_Fig05-01.jpg

图 5-1 。图形管道

顶点数据以流的形式提供给 GPU,有两种方法可以构建这些数据流。描述这些结构的常用术语有

  • 结构的数组;
  • 数组的结构。

我们将只看一系列结构的例子。原因是结构数组在流中交错顶点数据,这使得数据在内存中是连续的。当现代处理器需要的数据可以被预取到高速缓存中时,它们工作得最好,这是通过以块为单位从内存中抓取数据来实现的。如果处理器需要的下一组数据已经在块中,我们从内存中保存一个副本,这可能会使 GPU 停止工作。

清单 5-5 显示了一个四边形的结构数组,它将被顶点着色器转换。它由一个浮动数组组成,其中三个浮动描述顶点的位置,四个浮动描述顶点的颜色。然后,我们为渲染我们的对象所需的每个顶点重复相同的格式,在四边形的情况下是四个。

清单 6-5。 四边形顶点规格

float verts[] =
{
       0.5f, 0.5f, 0.0f,          // Position 1 x, y, z

       1.0f, 0.0f, 0.0f, 1.0f,     // Color 1 r, g, b, a
       0.5f, 0.5f, 0.0f,           // Position 2 x, y, z
       0.0f, 1.0f, 0.0f, 1.0f,     // Color 2 r, g, b, a
       0.5f, 0.5f, 0.0f,         // Position 3 x, y, z
       0.0f, 0.0f, 1.0f, 1.0f,     // Color 3 r, g, b, a
       0.5f, 0.5f, 0.0f,          // Position 4 x, y, z
       1.0f, 1.0f, 1.0f, 1.0f,     // Color 4 r, g, b, a
};

GPU 渲染三角形,因此为了能够渲染四边形,我们需要提供一些附加信息。我们可以按顺序提供六个顶点;然而,在总共 28 字节的 7 个浮点中,我们需要发送相同大小的重复顶点,当我们将这些顶点传输到 GPU 时,这会浪费一些内存和带宽。相反,我们发送一个索引流,描述 GPU 应该使用我们提供的顶点来渲染三角形的顺序。我们的索引可以在清单 5-6 中看到。

清单 6-6。 四联指数

unsigned short indices[] =
{
       0,     2,     1,     2,     3,     1
};

通过我们的索引,你可以看到我们每个顶点只上传了两个字节,因此即使在我们的简单例子中,我们也比指定副本节省了相当多的空间。现在我们来看看清单 5-7 中顶点着色器的代码。

清单 6-7。 一个基本顶点着色器

attribute vec4 a_vPosition;
attribute vec4 a_vColor;
varying vec4 v_vColor;
void main()
{
       gl_Position = a_vPosition;
       v_vColor = a_vColor;
}

前面的清单显示了一个用 GLSL 编写的非常基本的顶点着色器。前两行指定着色器的属性。我们的属性是来自我们提供的数据流的数据。尽管在清单 5-1 的中只指定了位置值的 x、y 和 z 元素,我们在这里使用了一个四元素向量(vec4)。当我们设置数据时,图形驱动程序可以填充这些附加信息。如您所见,我们为顶点的位置和颜色分别指定了一个属性。下一行指定了一个变化的。变量是一个输出变量,我们希望从这个顶点传递到我们的像素着色器。它有一个重要的特性,就是它从一个顶点到下一个顶点插值。

gl_Position变量是 GLSL 中的一个特殊变量,专门用于存储顶点着色器的输出顶点位置。有必要使用它,因为它表示需要通过后续管道阶段(如剪辑)传递的数据。我们可以从main()的第一行看到,我们只是将输入的顶点位置传递给这个变量。同样,我们也将输入的颜色传递给可变的颜色变量。

这就是我们现在拥有的简单顶点着色程序。下一个主要步骤是查看一个基本的片段着色器,并了解我们如何在片段着色器阶段访问顶点着色器的输出。

OpenGL ES 2.0 中片段着色器介绍

清单 5-8 显示了一个用 GLSL 写的基本片段着色器的代码。

清单 6-8。 基本片段着色器

varying vec4 v_vColor;
void main()
{
       gl_FragColor = v_vColor;
}

顶点着色器和片段着色器被捆绑在一起成为程序对象。为了使程序有效,来自顶点着色器的任何变化的对象都必须与片段着色器中相同类型和名称的变化相匹配。这里我们可以看到,我们有一个名为v_vColor的变量,它的类型是vec4。片段着色器的作用是为正在处理的像素提供颜色,GLSL 提供了gl_FragColor变量来存储这个输出结果;如您所见,我们将变量v_vColor的值存储到这个变量中。

这就是我们创建一个非常基本的片段着色器所需要的。结合前面的顶点着色器,我们有一个基本的着色器程序,可以渲染一个彩色图元。这种结构的好处是它的伸缩性非常好;与通用 CPU 相比,GPU 实现了高水平的性能,因为它们并行组合了多个顶点和着色器处理器,并同时执行多个顶点和片段的着色器。现代桌面 GPU 拥有统一的着色器处理器,可以执行顶点和片段着色器,并实现负载平衡器,以便在任何给定时间根据需求分配负载。我确信这是我们在不久的将来将在移动架构中看到的发展。

现在我们知道了顶点和像素着色器,我们将看看我们需要在代码中做些什么来将它们构建到着色器程序中。

创建着色器程序

由于我们的引擎将只支持 OpenGL ES 2.0,我们所有的渲染操作都将使用着色器。这给了我们一个清晰的设计目标,因为我们的渲染器在执行绘制调用之前必须设置一个着色器。我们还知道,我们可能想要为不同的对象指定不同的着色器操作。我们的一些着色器可能很复杂,并执行操作来为关键对象提供高质量的照明和材质属性。移动 GPU 无法在单帧中过于频繁地执行这些复杂的着色器,因此我们将不得不支持切换着色器,以支持更简单对象的更多基本操作,从而实现实时帧速率。为了实现这一点,我们将为着色器指定一个接口,,如清单 5-9 所示。

清单 6-9。 一个着色器界面

class Shader
{
private:
       void LoadShader(GLenum shaderType, std::string& shaderCode);

protected:
       GLuint               m_vertexShaderId;
       GLuint               m_fragmentShaderId;
       GLint                m_programId;

       std::string          m_vertexShaderCode;
       std::string          m_fragmentShaderCode;

       bool                 m_isLinked;

public:
       Shader();
       virtual Shader();

       virtual void Link();
       virtual void Setup(Renderable& renderable);

       bool IsLinked()      { return m_isLinked; }
};

清单 5-9 显示了Shader的类定义。它包含顶点和片段着色器以及程序对象的标识符。它还具有包含顶点和片段着色器的源代码的成员变量,以及一个用于跟踪着色器是否已链接的布尔值。

LoadShader方法用于将顶点和片段着色器的着色器代码加载、编译和附加到程序对象。在清单 5-10 中有规定。

清单 6-10。T5】着色器的 LoadShader 方法

void Shader::LoadShader(GLuint id, std::string& shaderCode)
{
       static const uint32_t NUM_SHADERS = 1;

       const GLchar* pCode = shaderCode.c_str();
       GLint length = shaderCode.length();

       glShaderSource(id, NUM_SHADERS, &pCode, &length);

       glCompileShader(id);

       glAttachShader(m_programId, id);
}

LoadShader首先获取一个指向源代码的指针和源代码的长度。然后我们调用glShaderSource将源代码设置到指定着色器 ID 的 GL 上下文中。调用glCompileShader编译源代码,glAttachShader将编译好的着色器对象附加到程序上。清单 5-11 展示了LoadShader方法是如何适应整个程序的上下文的。

清单 6-11。 着色器的链接方法

void Shader::Link()
{
       m_programId = glCreateProgram();

       m_vertexShaderId = glCreateShader(GL_VERTEX_SHADER);
       LoadShader(m_vertexShaderId, m_vertexShaderCode);

       m_fragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
       LoadShader(m_fragmentShaderId, m_fragmentShaderCode);

       glLinkProgram(m_programId);

       m_isLinked = true;

}

这里我们可以看到Link开始于调用glCreateProgram,它请求 GL 上下文创建一个新的着色器程序对象。我们无权访问该对象,而是返回一个标识符,我们在调用后续着色器方法时使用该标识符。然后我们要求 OpenGL 为我们创建一个VERTEX_SHADER对象,并用顶点着色器 id 和代码作为参数调用LoadShader。然后我们对一个FRAGMENT_SHADER对象重复这个过程。最后,我们调用glLinkProgram来完成着色器对象。

我们的Setup方法将用于告诉 OpenGL 上下文哪个着色器是下一个绘制调用的活动着色器。基类Shader在这一点上有一个非常基本的任务,并调用glUseProgram,如清单 5-12 中的所示。

清单 6-12。 Shader::Setup()

void Shader::Setup(Renderable& renderable)
{
       glUseProgram(m_programId);
}

用 OpenGL 渲染一个四边形

终于到了我们将第一个三角形渲染到屏幕上的时候了。这是创建游戏系统的重要一点,因为从这一点开始,我们渲染的所有图形都将是这个简单任务的扩展。游戏中所有复杂的模型和效果都源于渲染一系列三角形的能力,这些三角形是用一组顶点和一组索引创建的。这个简单的例子将向你展示如何使用由四个顶点和六个索引组成的两个三角形来渲染一个四边形,我们在本章前面的清单 5-5 和 5-6 中看到了。

表示几何图形

在我们的游戏中,表示顶点和索引可能是我们想要重复做的事情,因此将它们封装在一个类中是有意义的。我们将在我们的Geometry类中这样做,如清单 5-13 所示。

清单 6-13。 几何课

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_numColorElements;
       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);
       int  GetNumVertexPositionElements() const        { return m_numVertexPositionElements; }

       void SetNumColorElements(const int numColorElements);
       int  GetNumColorElements() const                 { return m_numColorElements; }

       void SetNumTexCoordElements(const int numTexCoordElements);
       int  GetNumTexCoordElements() const              { return m_numTexCoordElements; }

       void SetVertexStride(const int vertexStride)     { m_vertexStride = vertexStride; }
       int  GetVertexStride() const                     { return m_vertexStride; }
               };

       inline void Geometry::SetNumVertexPositionElements(const int numVertexPositionElements)
       {
              m_numVertexPositionElements = numVertexPositionElements;
       }

       inline void Geometry::SetNumTexCoordElements(const int numTexCoordElements)
       {
              m_numTexCoordElements = numTexCoordElements;
       }

       inline void Geometry::SetNumColorElements(const int numColorElements)
       {
              m_numColorElements = numColorElements;
       }

清单 5-13 给出了Geometry类的定义。除了存储指向顶点和索引的指针,该类还包含用于描述顶点数据如何存储在数组中的字段。这些成员包括顶点和索引的数量,还包括位置数据中位置元素的数量、颜色元素的数量以及每个顶点的纹理坐标元素的数量。我们还有一个存储顶点步距的字段。步幅是我们从一个顶点跳到下一个顶点的字节数,当我们向 OpenGL 描述数据时,这是必需的,我们很快就会看到。

首先,我们来看看如何创建一个渲染器可以使用的对象。

创建可渲染的

我们知道,Renderer的工作是将Geometry提供给 OpenGL API,以便绘制到屏幕上。因此,我们能够以一致的方式描述Renderer应该考虑的对象是有意义的。清单 5-14 显示了我们将用来发送Renderable对象到渲染器进行绘制的类。

清单 6-14。 定义一可呈现

class Renderable
{
private:
       Geometry*            m_pGeometry;
       Shader*              m_pShader;

public:
       Renderable();
       Renderable();

       void                 SetGeometry(Geometry* pGeometry);
       Geometry*            GetGeometry();

       void                 SetShader(Shader* pShader);
       Shader*              GetShader();
};

inline Renderable::Renderable()
       :      m_pGeometry(NULL)
       ,      m_pShader(NULL)
{
}

inline Renderable::Renderable()
{
}

inline void Renderable::SetGeometry(Geometry* pGeometry)
{
       m_pGeometry = pGeometry;
}

inline Geometry* Renderable::GetGeometry()
{
       return m_pGeometry;
}

inline void Renderable::SetShader(Shader* pShader)
{
       m_pShader = pShader;
}

inline Shader* Renderable::GetShader()
{
       return m_pShader;
}

目前这是一个简单的类,因为它只包含一个指向一个Geometry对象和一个Shader对象的指针。这是另一个将随着我们的前进而发展的类。

我们还需要扩充Renderer类来处理这些Renderable对象。清单 5-15 展示了Renderer如何处理我们添加的要绘制的对象。

清单 6-15。 更新渲染器

class Renderer
{
private:
       typedef std::vector<Renderable*>               RenderableVector;
       typedef RenderableVector::iterator               RenderableVectorIterator;

       RenderableVector               m_renderables;

       void Draw(Renderable* pRenderable);

public:
       void AddRenderable(Renderable* pRenderable);
       void RemoveRenderable(Renderable* pRenderable);
}

void Renderer::AddRenderable(Renderable* pRenderable)
{
       m_renderables.push_back(pRenderable);
}

void Renderer::RemoveRenderable(Renderable* pRenderable)
{
       for (RenderableVectorIterator iter = m_renderables.begin();
            iter != m_renderables.end();
            ++iter)
       {
              Renderable* pCurrent = *iter;
              if (pCurrent == pRenderable)
              {
                     m_renderables.erase(iter);
                     break;
              }
       }
}

void Renderer::Update()
{
       if (m_initialized)
       {
              glClearColor(0.95f, 0.95f, 0.95f, 1);
              glClear(GL_COLOR_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);
       }
}

我们将Renderable对象存储在vector中,并在调用Update的过程中循环这些对象。每个Renderable然后被传递给私有的Draw方法,我们在清单 5-16 中描述了这个方法。

清单 6-16。 渲染器的绘制方法

void Renderer::Draw(Renderable* pRenderable)
{
       assert(pRenderable);
       if (pRenderable)
       {
              Geometry* pGeometry = pRenderable->GetGeometry();
              Shader* pShader = pRenderable->GetShader();
              assert(pShader && pGeometry);

              pShader->Setup(*pRenderable);

              glDrawElements(
                     GL_TRIANGLES,
                     pGeometry->GetNumIndices(),
                     GL_UNSIGNED_SHORT,
                     pGeometry->GetIndexBuffer());
       }
}

我们的Draw方法显示,我们对每个对象只执行两个任务。在验证了我们有一个有效的Renderable指针并且我们的 Renderable 包含有效的GeometryShader指针之后,我们调用Shader::Setup(),然后调用glDrawElementsglDrawElements 传递参数,让上下文知道我们想要渲染三角形,传递多少个索引,索引的格式,以及索引缓冲区本身。

您可能会注意到,我们没有向 draw 调用传递任何有关顶点的信息。这是因为此信息是着色器设置阶段的一部分,并作为数据流传递给着色器。现在,我们将看看如何处理向着色器传递数据。

创建基本着色器

前面,我们看了一个在我们的框架中表示着色器的基类;现在我们来看一个具体的实现。为了创建一个我们可以在 GPU 上使用的着色器,我们将从从Shader类派生一个新类开始。清单 5-17 显示了BasicShader类。

清单 6-17。 最基本的 Shader 类

class BasicShader
       :      public Shader
{
private:
       GLint         m_positionAttributeHandle;

public:
       BasicShader();
       virtual BasicShader();

       virtual void Link();
       virtual void Setup(Renderable& renderable);
};

正如你从清单 5-17 中看到的,我们继承了Shader并重载了它的公共方法。我们还添加了一个字段来存储 GL 上下文中 position 属性的索引。为了简单起见,这个着色器将直接从片段着色器渲染颜色,并将放弃我们之前看到的流中的颜色值。清单 5-18 显示了包含着色器源代码的BasicShader类构造器。

清单 6-18。basic shader 构造函数

BasicShader::BasicShader()
{
       m_vertexShaderCode =
              "attribute vec4 a_vPosition; \n"
              "void main(){\n"
              "     gl_Position = a_vPosition; \n"
              "} \n";

       m_fragmentShaderCode =
              "precision highp float; \n"
              "void main(){\n"
              "    gl_FragColor = vec4(0.2, 0.2, 0.2, 1.0); \n"
              "} \n";
}

注意片段着色器源代码的第一行为着色器设置浮点变量的精度。可变精度是一个高级话题,我们在这里不讨论。开始时,您需要了解的最基本知识是,在 OpenGL ES 2.0 中,片段着色器必须声明浮点的默认精度有效。在本文中,我们将始终使用值highp

如你所见,我们的basic着色器简单地设置输出位置以匹配输入顶点位置,我们的片段着色器将颜色设置为深灰色。我们现在来看看需要覆盖的方法。第一个如清单 5-19 所示。

清单 6-19。 基础连接法

void BasicShader::Link()
{
       Shader::Link();

       m_positionAtributeHandle = glGetAttribLocation(m_programId, "a_vPosition");
}

这里你可以看到我们首先需要调用我们的父类的'Link方法。这确保了着色器已经被编译并链接到我们的程序对象中。我们接着叫glGetAttribLocation;这个方法返回给我们a_vPosition属性的索引,我们将在下一个方法Setup中使用,如清单 5-20 所示。

注意每次您希望为位置属性设置顶点流时,都可以使用属性名称,但是最好查询位置,因为这比每次调用时通过名称查找位置要快得多。

清单 6-20。 BasicShader::Setup()

void BasicShader::Setup(Renderable& renderable)
{
       Shader::Setup(renderable);

       Geometry* pGeometry = renderable.GetGeometry();
       assert(pGeometry);

       glVertexAttribPointer(
              m_positionAttributeHandle,
              pGeometry->GetNumVertexPositionElements(),
              GL_FLOAT,
              GL_FALSE,
              pGeometry->GetVertexStride(),
              pGeometry->GetVertexBuffer());
       glEnableVertexAttribArray(m_positionAttributeHandle);
}

在这个方法中,我们再次调用我们的父类,以确保基础上所需的任何操作都已完成,并且我们的着色器已准备好使用。

然后我们调用glVertexAttribPointer OpenGL 方法来指定顶点流。glVertexAttribPointer的论据如下:

  • 第一个参数是属性在我们描述的着色器中的位置。在这种情况下,我们只有顶点位置的数据。
  • 第二个参数告诉 OpenGL 每个顶点包含多少个元素。该值可以是 1、2、3 或 4。在我们的例子中,它是三,因为我们指定了顶点的 x,y 和 z 坐标。
  • 第三个参数指定该位置使用的数据类型。
  • 第四个决定我们是否希望值被规范化。
  • 然后我们传递一个参数,告诉 OpenGL 从这个顶点的数据开始跳到下一个顶点需要多少字节,这个参数称为步距。众所周知,当顶点之间没有数据,或者它们被紧密地压缩时,零是一个有效值。当我们查看需要非零值的顶点数据时,我们将更详细地查看步幅。
  • 最后但同样重要的是,我们传递一个指向内存地址的指针,在内存中可以找到对象的顶点数据。

在我们可以使用着色器之前,我们需要调用glEnableVertexAttribArray来确保 OpenGL 上下文知道数据已经准备好使用。

现在我们有了一个可以在程序中实例化和使用的着色器以及GeometryRenderable类,让我们创建一个可以使用它们在屏幕上绘制四边形的应用。

创建特定于应用的应用和任务

我们创建的每个应用都可能包含不同的功能。我们希望有不同的菜单、不同的关卡和不同的游戏方式。为了区分应用之间的功能,我们可以将我们的Framework Application类继承到一个特定于应用的实现中,并在其中包含一个Task,如清单 5-21 中的所示。

清单 6-21。 第五章任务

class Chapter5Task
       :      public Framework::Task
{
private:
       State                                     m_state;

       Framework::Renderer*                      m_pRenderer;
               Framework::Geometry               m_geometry;
               Framework::BasicShader            m_basicShader;
               Framework::Renderable             m_renderable;

public:
       Chapter5Task(Framework::Renderer* pRenderer, const unsigned int priority);
       virtual Chapter5Task();

       // From Task
       virtual bool                Start();
       virtual void                OnSuspend();
       virtual void                Update();
       virtual void                OnResume();

       virtual void               Stop();
};

清单 5-21 显示了这个应用的Task。它包含一个指向Renderer的指针和代表Geometry、一个BasicShader和一个Renderable的成员。使用这些相当简单。

清单 5-22 显示了构造器所需的基本设置。

清单 6-22。 第五章任务构造器

Chapter5Task::Chapter5Task(Framework::Renderer* pRenderer, const unsigned int priority)
       :      m_pRenderer(pRenderer)
       ,      Framework::Task(priority)
{
       m_renderable.SetGeometry(&m_geometry);
       m_renderable.SetShader(&m_basicShader);
}

在这里,我们将m_pRenderer设置为传入的Renderer,并使用我们指定的优先级调用Task构造函数。

我们还用相应参数的成员变量的地址调用m_renderable上的SetGeometrySetShader

在清单 5-23 中,我们看看当Task被添加到内核时需要发生什么。

清单 6-23。 第五章任务开始

namespace
{
       float verts[] =
       {
              0.5f, 0.5f, 0.0f,
              0.5f, 0.5f, 0.0f,
              0.5f, 0.5f, 0.0f,
              0.5f, 0.5f, 0.0f,
       };

       unsigned short indices[] =
       {
              0,     2,     1,     2,     3,     1
       };
}

bool Chapter5Task::Start()
{
       Framework::Geometry* pGeometry = m_renderable.GetGeometry();
       pGeometry ->SetVertexBuffer(verts);
       pGeometry ->SetNumVertices(4);
       pGeometry ->SetIndexBuffer(indices);
       pGeometry ->SetNumIndices(6);
       pGeometry ->SetName("quad");

       pGeometry ->SetNumVertexPositionElements(3);
       pGeometry ->SetVertexStride(0);

       m_pRenderer->AddRenderable(&m_renderable);

       return true;
}

这里,我们在方法声明之前,在本地匿名名称空间中指定顶点和索引数据。将来,我们将从文件中加载这些数据。

Start方法从Renderable对象获取有效指针,然后设置所有相关数据。设置了顶点和索引缓冲区以及大小,我们给对象一个名称,并将每个顶点的位置元素的数量设置为 3,跨距设置为零。

然后我们将m_renderable添加到Renderer中进行绘制。

Stop方法 ( 清单 5-24 )有一个简单的任务,那就是从渲染器中移除可渲染对象。析构函数也应该这样做。

清单 6-24。 第五章任务停止

void Chapter5Task::Stop()
{
       m_pRenderer->RemoveRenderable(&m_renderable);
}

我们现在来看看如何将Chapter5Task添加到Kernel中,如清单 5-25 所示。

清单 6-25。 第五章 App

class Chapter5App
       :      public Framework::Application
{
private:
       Chapter5Task         m_chapter5Task;

public:
       Chapter5App(android_app* pState);
       virtual Chapter5App();

       virtual bool Initialize();
};

创建Chapter5App类就像从Application继承一样简单。我们覆盖了Initialize方法并添加了一个类型为Chapter5Task的成员。

Chapter5App的方法非常简单,如清单 5-26 所示。

清单 6-26。 第五章 App 方法

Chapter5App::Chapter5App(android_app* pState)
       :      Framework::Application(pState)
       ,      m_chapter5Task(&m_rendererTask, Framework::Task::GAME_PRIORITY)
{
}

bool Chapter5App::Initialize()
{
       bool success = Framework::Application::Initialize();

        if (success)
       {
              m_kernel.AddTask(&m_chapter5Task);
       }

       return success;
}

您在这里看到的简单性是我们将所有将在未来应用之间共享的任务隐藏到代码的Framework层的结果。我们正在创建一个可重用的库,希望您开始看到的好处将在下一节中更加明显。我们的简单构造函数有一个简单的任务,即调用它的父对象并初始化Chapter5Task对象。

Initialize简单地调用它的父对象,如果一切正常,就将Chapter5Task对象添加到内核中。

到目前为止,我们做得很好,但是我们现在看到的代码只会在屏幕上呈现一个空白的四边形,这不是特别有趣。输出的截图是图 5-2 中的。

9781430258308_Fig05-02.jpg

图 5-2 。基本着色器的渲染输出

让我们快速地转到如何渲染一个有纹理的四边形。

将纹理应用到几何体

在代码中指定几何图形和顶点是一项简单的任务。以同样的方式表示纹理数据将是一个困难得多的命题。我可以给你一个代码格式的预设纹理;然而,现在似乎是用安卓 NDK 加载文件的最佳时机。

加载文件

“文件”这个词显然是一个名词,基本的面向对象设计告诉我们,名词是成为类的很好的候选,所以我们将从这里开始。清单 5-27 中的显示了File类的接口。

清单 6-27。 文件类界面

class File
{
public:
       explicit File(std::string name);
       virtual File();

       bool          Open();
       void          Read(void* pBuffer, const unsigned int bytesToRead, size_t& bytesRead);
       void          Close();

       unsigned int  Length() const;
};

该接口定义了我们希望在单个文件上执行的基本操作。现在我们来看看 NDK 提供的实现这些操作的函数。

如果您查看在项目中创建的文件夹,您应该会看到一个名为assets的文件夹。我们希望从我们的应用中访问的任何文件都将添加到该文件夹中。NDK 类为我们提供了这个文件夹的接口对象,称为AAssetManager。我们只需要一个对AAssetManager对象的引用,所以我们在File类中创建一个指向它的静态指针,如清单 5-28 所示。

清单 6-28。 向文件中添加 AAssetManager

class File
{
private:
       static AAssetManager* m_pAssetmanager;

public:
       static void SetAssetManager(AAssetManager* pAssetManager)
       {
              m_pAssetManager = pAssetmanager;
       }
};

为了确保在创建一个File的实例之前设置它,在构造函数中断言指针不为NULL是一个好主意,如清单 5-29 所示。

清单 6-29。 文件构造器

File::File(std::string name)
{
       assert(m_pAssetManager != NULL);
}

对文件执行的第一个操作是打开它。我们通过调用AAssetManager_open来做到这一点,如清单 5-30 所示。

清单 6-30。 文件打开

bool File::Open()
{
       m_pAsset = AAssetManager_open(m_pAssetManager, m_filename.c_str(), AASSET_MODE_UNKNOWN);
       return !!m_pAsset;
}

如您所见,这相对简单。您需要在类定义中添加一个AAsset指针和一个文件名字符串来表示m_pAssetm_filenamem_filename可以用传入File的构造函数的字符串初始化。

此时,我们可以向 NDK 询问文件的字节长度,如清单 5-31 所示。

清单 6-31。 文件长度

unsigned int File::Length() const
{
       return AAsset_getLength(m_pAsset);
}

我们也可以在完成后关闭文件,如清单 5-32 所示。

清单 6-32。 文件关闭

void File::Close()
{
       if (m_pAsset)
       {
              AAsset_close(m_pAsset);
              m_pAsset = NULL;
       }
}

在关闭程序之前,最好确保所有文件都已关闭;因此,我也建议从File的析构函数中调用Close(清单 5-33 )。

清单 6-33。∾文件

File::File()
{
       Close();
}

现在,对于File类的真正主力Read方法,如清单 5-34 所示。

清单 6-34。 文件的读取方法

void File::Read(void* pBuffer, const unsigned int bytesToRead, size_t& bytesRead)
{
       bytesRead = AAsset_read(m_pAsset, pBuffer, bytesToRead);
}

几乎不复杂,但有一个很好的理由。许多文件类型都有文件头,程序可能希望读取这些文件头,而不必读取一个大文件的全部内容。这对于作为其他文件集合的文件来说尤其如此。

由于File类本身不可能知道调用它的代码的意图,所以我们不会给它添加任何不必要的代码。接下来我们将看看如何处理一个纹理文件。

加载 TGA 文件

TGA 文件在游戏开发中被广泛使用。它们被广泛采用有一个简单的原因:它们非常容易读写,并且支持游戏所需的所有信息,包括 alpha 通道。TGA 格式中还指定了一个开发人员区域,开发人员可以根据自己的意愿使用该区域,这使得该格式非常灵活。现在,我们将处理一个基本的 TGA 文件。清单 5-35 显示了 TGA 文件头的精确字节模式。

清单 6-35。 TGAHeader

struct TGAHeader
{
       unsigned char        m_idSize;
       unsigned char        m_colorMapType;
       unsigned char        m_imageType;

       unsigned short       m_paletteStart;
       unsigned short       m_paletteLength;
       unsigned char        m_paletteBits;

       unsigned short       m_xOrigin;
       unsigned short       m_yOrigin;
       unsigned short       m_width;
       unsigned short       m_height;
       unsigned char        m_bpp;
       unsigned char        m_descriptor;
} __attribute__ ((packed));

这个 18 字节的部分存在于每个有效的 TGA 文件的开头,并且总是采用相同的格式。目前许多数据对我们来说是不必要考虑的。最初,我们将处理未压缩的位图数据。虽然 TGA 文件可以支持压缩和托盘化纹理,但它们不是 OpenGL 支持的格式,因此我们将避免创建这种格式的纹理。在我们有一个普通位图文件的情况下,标题中唯一感兴趣的字段是widthheightbppbpp代表每个像素的字节数,值 1 表示我们正在处理灰度图像,值 3 表示 RGB,值 4 表示 RGBA。我们可以通过计算m_width * m_height * m_bpp来计算出标题后面的图像数据的大小。

不幸的是,我们不得不在这个时候涵盖一个相对先进的概念。当我们加载文件数据时,我们将从内存中加载整个 18 字节的文件头,或者整个文件。然后,我们可以将指向从文件中加载的数据的指针转换成一个TGAHeader指针;以这种方式使用强制转换可以避免将加载的数据复制到结构中,这通常称为内存映射。在这样做的时候,__attribute__ ((packed))指令是必不可少的。它的工作是确保编译器不会在结构中的成员之间添加任何填充。例如,前三个字段m_idSizem_colorMapTypem_imageType,用三个字节表示。大多数处理器在从在一定数量的字节的边界上对齐的存储器地址复制和访问数据方面更有效。因此,编译器可以通过跳过第四个字节并将m_paletteStart存储在下一个可被 4 整除的地址来填充结构。

这给我们带来的问题是,不同的编译器可以随意地为它们所针对的处理器填充,而我们从内存中加载的文件保证没有任何填充;这意味着编译器可能会使结构字段的地址与二进制块中数据的位置不匹配。结构定义末尾的__attribute__ ((packed))行阻止编译器添加我们不想要的填充。

抱歉,在困难中稍微绕道和颠簸。如果最后一条信息有点复杂,请放心,您不必确切地理解此时此刻正在发生什么;你只需要知道在这种情况下它是需要的。我还把它添加到了本书中其他需要的地方,这样你就不用担心以后会不会得到正确的答案。

让我们从整体上看一下TGAFile类(参见清单 5-36 )。

清单 6-36。 TGAFile

class TGAFile
{
public:
       struct TGAHeader
       {
              unsigned char        m_idSize;
              unsigned char        m_colorMapType;
              unsigned char        m_imageType;

              unsigned short       m_paletteStart;
              unsigned short       m_paletteLength;
              unsigned char        m_paletteBits;

              unsigned short       m_xOrigin;

              unsigned short       m_yOrigin;
              unsigned short       m_width;
              unsigned short       m_height;
              unsigned char        m_bpp;
              unsigned char        m_descriptor;
       } __attribute__ ((packed));

       TGAFile(void* pData);
       virtual TGAFile();

       unsigned short              GetWidth() const;
       unsigned short              GetHeight() const;
       void*                       GetImageData() const;

private:
       TGAHeader*                  m_pHeader;
       void*                       m_pImageData;
};

inline unsigned short TGAFile::GetWidth() const
{
       unsigned short width = m_pHeader
              ?     m_pHeader->m_width
              :     0;
       return width;
}

inline unsigned short TGAFile::GetHeight() const
{
       unsigned short height = m_pHeader
              ?     m_pHeader->m_height
              :     0;
       return height;
}

inline void* TGAFile::GetImageData() const
{
       return m_pImageData;
}

这在很大程度上很容易理解。现在我们可以看看如何将纹理呈现给Renderer

代表一个 GL 纹理

纹理在计算机图形学中被用来给平面提供比单独使用几何图形更多的细节。典型的例子是砖墙。砖块本身表面粗糙,砖块之间的砂浆通常与砖块颜色不同。

使用纯粹的几何方法来表示这些表面将需要比我们在实时帧速率下可能处理的更多的顶点。我们通过在表面上绘制图像来伪造表面的外观,从而绕过处理限制。这些图像是纹理。

纹理现在被用于许多目的。它们可以通过定义多边形上每个像素的颜色以传统方式使用。它们现在也用于不同的应用,例如绘制法线和照明数据。这些分别被称为法线贴图和光照贴图。在初级阶段,我们将坚持传统的使用方法,并在本章中看看我们如何使用纹理贴图。清单 5-37 展示了我们如何用代码表示一个纹理贴图。

清单 6-37。 框架的纹理类

class Texture
{
public:
       struct Header
       {
              unsigned int               m_width;
              unsigned int               m_height;
              unsigned int               m_bytesPerPixel;
              unsigned int               m_dataSize;

              Header()
                     :      m_width(0)
                     ,      m_height(0)
                     ,      m_bytesPerPixel(0)
                     ,      m_dataSize(0)
              {
              }

              Header(const Header& header)
              {
                     m_width              = header.m_width;
                     m_height             = header.m_height;
                     m_bytesPerPixel      = header.m_bytesPerPixel;
                     m_dataSize           = header.m_dataSize;
              }
       };

private:
       GLuint        m_id;
       Header        m_header;
       void*         m_pImageData;

public:
       Texture();
       Texture();

       void SetData(Header& header, void* pImageData);

       GLuint GetId() const { return m_id; }

       void Init();
};

这个类是另一个相当简单的事情,并且大部分是自文档化的。它接受一些指针,并将描述纹理数据所需的信息存储在一个名为Header的结构中。一个值得好好研究的方法是Init ,我们在清单 5-38 中做了这个。

清单 6-38。 纹理的初始化

void Texture::Init()
{
       GLint  packBits             = 4;
       GLint  internalFormat       = GL_RGBA;
       GLenum format               = GL_RGBA;
       switch (m_header.m_bytesPerPixel)
       {
       case 1:
       {
              packBits             = 1;
              internalFormat       = GL_ALPHA;
              format               = GL_ALPHA;
       }
       break;
       };

       glGenTextures(1, &m_id);

       glBindTexture(GL_TEXTURE_2D, m_id);

       glPixelStorei(GL_UNPACK_ALIGNMENT, packBits);

       glTexImage2D(
              GL_TEXTURE_2D,
              0,
              internalFormat,
              m_header.m_width,
              m_header.m_height,
              0,
              format,
              GL_UNSIGNED_BYTE,
              m_pImageData);
}

现在,Init被写来仅仅处理GL_RGBA或者GL_ALPHA纹理。glGenTextures创建一个新的纹理,通过参数引用返回一个 id。一次创建多个纹理是可能的,但是现在我们很乐意一次创建一个纹理。

glBindTexture 用于将指定 ID 的纹理附加到指定的纹理单元,并将纹理锁定到该类型。目前,我们只对传统的二维纹理感兴趣,所以我们在第一个参数中指定了这一点。

glPixelStorei 告知 OpenGL 每个像素有多少字节。对于灰度,我们每像素一个字节,对于 RGBA 纹理,我们有四个字节。具体来说,这个函数告诉 OpenGL 它应该如何将纹理读入自己的内存。

我们接着用glTexImage2D 。这个函数让 OpenGL 上下文将图像数据从我们的源数组复制到它自己的可用内存空间中。这些参数如下:

  • target -要读入的纹理单元,在我们的例子中是GL_TEXTURE_2D
  • level -要读入的 mip 级别;现在我们只对零级感兴趣。
  • internalFormat -要复制到的纹理的格式。这可能与format不同,但是我们没有使用这个功能。
  • width -纹理的宽度,以像素为单位。
  • height -纹理的高度,以像素为单位。
  • border -该值必须始终为零。
  • format -源像素数据的格式。我们使用GL_ALPHAGL_RGBA,并将匹配传递给 internalFormat 的值。
  • type -单个像素的数据类型。我们使用无符号字节。
  • data -指向图像数据中第一个像素的指针。

一旦这个函数被成功调用,我们将在创建的 ID 上有一个可用的纹理。

创建 TextureShader

现在我们知道了纹理在 OpenGL 中的样子,我们可以编写着色器来将纹理应用到几何图形中。我们从再次继承清单 5-39 中Shader的一个新类开始。

清单 6-39。texture shader 类

class TextureShader
       :      public Shader
{
private:
       Texture*      m_pTexture;
       GLint         m_positionAttributeHandle;
       GLint         m_texCoordAttributeHandle;
       GLint         m_samplerHandle;

public:
       TextureShader();
       virtual TextureShader();

       virtual void  Link();
       virtual void  Setup(Renderable& renderable);

       void          SetTexture(Texture* pTexture);
       Texture*      GetTexture();
};

代码并不比我们之前创建的BasicShader复杂多少。突出的区别是,我们没有纹理坐标的属性句柄,也没有采样器的属性句柄,我将在下面的文本中详细解释。让我们来看看TextureShader的构造函数,如清单 5-40 所示。

清单 6-40。texture shader 构造函数

TextureShader::TextureShader()
       :      m_pTexture(NULL)
{
       m_vertexShaderCode =
              "attribute vec4 a_vPosition;                            \n"
              "attribute vec2 a_texCoord;                             \n"
              "varying   vec2 v_texCoord;                             \n"
              "void main(){                                           \n"
              "    gl_Position = a_vPosition;                         \n"
              "    v_texCoord = a_texCoord;                           \n"
              "}                                                      \n";

       m_fragmentShaderCode =
              "precision highp float;                                 \n"
              "varying vec2 v_texCoord;                               \n"
              "uniform sampler2D s_texture;                           \n"
              "void main(){                                           \n"
              "    gl_FragColor = texture2D(s_texture, v_texCoord);   \n"
              "}                                                      \n";
}

着色器代码现在应该看起来有点熟悉了。我们有对应于传入顶点着色器的数据的属性,一个表示位置,另一个表示纹理坐标。然后,我们还有一个名为v_textCoord的变量,它将用于插值当前纹理坐标,以便在片段着色器中进行处理。您可以看到这种变化是在顶点着色器中设置的,我们将纹理坐标属性传递给变化。

片段着色器引入了一个新概念,即采样器。采样是 GPU 获取纹理坐标并查找纹理元素颜色的过程。请注意术语的变化:当谈到纹理时,我们倾向于将单个元素作为纹理元素而不是像素来谈论。

当在讨论纹理中查找纹理元素时,坐标本身通常也称为 UV 坐标。u 对应通常的 x 轴,V 对应 y 轴。UV 坐标的原点在位置(0,0)处,位于图像的左上角。坐标被指定为范围从 0 到 1 的数字,其中 0 处的 U 是左手边,1 是右手边,这同样适用于从上到下的 V。

程序以熟悉的方式访问着色器变量的位置。正如你在清单 5-41 中看到的,我们通过使用glGetUniformPosition而不是glGetAttribLocation来访问采样器的位置。

清单 6-41。 TextureShader 链接

void TextureShader::Link()
{
       Shader::Link();

       m_positionAttributeHandle   = glGetAttribLocation(m_programId, "a_vPosition");
       m_texCoordAttributeHandle   = glGetAttribLocation(m_programId, "a_texCoord");
       m_samplerHandle             = glGetUniformLocation(m_programId, "s_texture");
}

剩下要做的最后一件事是设置我们的着色器以备使用,如清单 5-42 所示。

清单 6-42。 纹理着色器设置

void TextureShader::Setup(Renderable& renderable)
{
       assert(m_pTexture);
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry && m_pTexture)
       {
              Shader::Setup(renderable);

              glActiveTexture(GL_TEXTURE0);
              glBindTexture(GL_TEXTURE_2D, m_pTexture->GetId());
              glUniform1i(m_samplerHandle, 0);

              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

              glVertexAttribPointer(
                     m_positionAttributeHandle,
                     pGeometry->GetNumVertexPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     pGeometry->GetVertexBuffer());
                     glEnableVertexAttribArray(m_positionAttributeHandle);

              glVertexAttribPointer(
                     m_texCoordAttributeHandle,
                     pGeometry->GetNumTexCoordElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     &static_cast<GLfloat*>(pGeometry->GetVertexBuffer())[pGeometry->GetNumVertexPositionElements()]);
              glEnableVertexAttribArray(m_texCoordAttributeHandle);
               }
}

清单 5-42 向我们展示了设置一个纹理所需要的着色器。在调用父对象的Setup方法后,我们使用glActiveTexture激活一个带有 OpenGL 的纹理采样器供我们使用。然后我们将我们的纹理附加到GL_TEXTURE_2D单元,并将我们的采样器位置设置为纹理单元零。这些步骤对于 OpenGL 在我们的着色器中正确的位置设置正确的纹理是必要的。

下一步是为我们的纹理设置一个包装格式。使用包装值可以实现某些效果。例如,通过指定小于或大于零和一的纹理坐标,可以使纹理重复或镜像。在我们的例子中,我们将简单地将纹理坐标设置为 0 到 1 之间的范围。

然后我们指定一个过滤类型。当纹理不是以每个屏幕像素一个纹理元素绘制时,过滤被应用于纹理。当纹理元素远离相机时,在单个像素内可以看到多个纹理元素。线性过滤对纹理表面上 UV 坐标指向的点周围的块中的四个像素进行平均。结果是图像有点模糊,虽然这听起来可能不理想,但它有助于减少物体靠近或远离相机时纹理的闪烁效果。

然后,我们将顶点数据指定给 OpenGL,就像我们在BasicShader中所做的那样。指定顶点数据后,我们指定纹理坐标数据。除了我们将纹理坐标属性位置和纹理坐标元素的数量传递给glVertexAttribPointer之外,大部分内容看起来都是一样的。然后,我们需要将第一个纹理坐标的地址传递给最后一个参数。请记住,我们之前讨论过,我们将使用一个数组结构格式的数据,这意味着我们的顶点属性是交织成一个单一的数组。您可以看到,我们通过将顶点缓冲区转换为浮点指针来计算第一个纹理坐标的地址,然后使用带有位置元素数量的数组索引来将指针跳转到第一个位置。

这是我们设置纹理和着色器所需要的。我们现在可以看看如何确保 OpenGL 处于正确的状态来处理我们的纹理和着色器。

初始化纹理和着色器

正如我们在初始化 OpenGL 时看到的,API 有一个上下文,它是在 Android 操作系统通知我们已经为我们的应用创建了窗口结构时建立的。我们还看到了如何设置纹理和着色器,包括从已链接或附加的着色器程序中获取变量的位置。这些过程是在当前背景下进行的。这意味着我们需要一个有效的上下文,然后才能在着色器和纹理上执行这些操作。这也意味着,如果上下文被破坏,这种情况发生在用户将手机置于睡眠状态时,那么每个纹理和着色器都必须重新初始化。

为了确保这是可能的,我们将添加一个正在使用的纹理和着色器向量到Renderer,如列表 5-43 所示。

清单 6-43。 渲染器的纹理和着色器矢量

class Renderer
{
private:
       typedef std::vector<Shader*>              ShaderVector;
       typedef ShaderVector::iterator            ShaderVectorIterator;

       typedef std::vector<Texture*>             TextureVector;

       typedef TextureVector::iterator       TextureVectorIterator;

public:
       void AddShader(Shader* pShader);
       void RemoveShader(Shader* pShader);

       void AddTexture(Texture* pTexture);
       void RemoveTexture(Texture* pTexture);
};

void Renderer::AddShader(Shader* pShader)
{
       assert(pShader);
       if (m_initialized)
       {
              pShader->Link();
       }
       m_shaders.push_back(pShader);
}

void Renderer::RemoveShader(Shader* pShader)
{
       for (ShaderVectorIterator iter = m_shaders.begin(); iter != m_shaders.end(); ++iter)
       {
              Shader* pCurrent = *iter;
              if (pCurrent == pShader)
              {
                     m_shaders.erase(iter);
                     break;
              }
       }
}

void Renderer::AddTexture(Texture* pTexture)
{
       assert(pTexture);
       if (m_initialized)
       {
              pTexture->Init();
       }
       m_textures.push_back(pTexture);
}

void Renderer::RemoveTexture(Texture* pTexture)
{
       for (TextureVectorIterator iter = m_textures.begin(); iter != m_textures.end(); ++iter)
       {
              Texture* pCurrent = *iter;
              if (pCurrent == pTexture)
              {
                     m_textures.erase(iter);
                     break;
              }
       }
}

前面的代码是我们维护当前使用的纹理和着色器列表所需的全部内容。当手机唤醒并且Renderer已被初始化时,为了重新初始化它们,或者为了初始化在Renderer准备好之前添加的任何代码,我们在 OpenGL 设置完成后,将来自清单 5-44 的代码添加到Renderer::Init

清单 6-44。 重新初始化纹理和着色器

for (TextureVectorIterator iter = m_textures.begin(); iter != m_textures.end(); ++iter)
{
       Texture* pCurrent = *iter;
       pCurrent->Init();
}

for (ShaderVectorIterator iter = m_shaders.begin(); iter != m_shaders.end(); ++iter)
{
       Shader* pCurrent = *iter;
       pCurrent->Link();
}

在任务中加载纹理

在我们加载一个纹理之前,我们需要指定相关的变量。我们在清单 5-45 中这样做。

清单 6-45。 给第五章任务添加纹理

class Chapter5Task
       :      public Framework::Task
{
private:
       enum State
       {
              LOADING_FILE,
              CREATE_TEXTURE,
              RUNNING
       };

       State                       m_state;

       Framework::File             m_file;
       Framework::Renderer*        m_pRenderer;
       Framework::Geometry         m_geometry;
       Framework::TextureShader    m_textureShader;
       Framework::Renderable       m_renderable;
       Framework::Texture          m_texture;

       void*                       m_pTGABuffer;
       unsigned int                m_readBytes;
       unsigned int                m_fileLength;
};

在我们学习修改后的方法时,我们将看看它们各自的用途。让我们从构造函数开始,如清单 5-46 所示。

清单 6-46。 第五章任务构造器

Chapter5Task::Chapter5Task(Framework::Renderer* pRenderer, const unsigned int priority)
       :      m_pRenderer(pRenderer)
       ,      Framework::Task(priority)
       ,      m_state(RUNNING)
       ,      m_file("test.tga")
       ,      m_pTGABuffer(NULL)
       ,      m_readBytes(0)
{
       m_renderable.SetGeometry(&m_geometry);
       m_renderable.SetShader(&m_textureShader);
}

在这里,你可以看到我们已经用默认值设置了变量,包括指定文件名test.tga

清单 5-47 展示了Start方法。

清单 6-47。 第五章任务开始

float verts[] =
{
       0.5f, 0.5f, 0.0f,
       0.0f, 1.0f,
       0.5f, 0.5f, 0.0f,
       1.0f, 1.0f,
       0.5f, 0.5f, 0.0f,
       0.0f, 0.0f,
       0.5f, 0.5f, 0.0f,
       1.0f, 0.0f
};

bool Chapter5Task::Start()
{
       Framework::Geometry* pGeometry = m_renderable.GetGeometry();
       pGeometry->SetVertexBuffer(verts);
       pGeometry->SetNumVertices(4);
       pGeometry->SetIndexBuffer(indices);
       pGeometry->SetNumIndices(6);
       pGeometry->SetName("quad");

       pGeometry->SetNumVertexPositionElements(3);
       pGeometry->SetNumTexCoordElements(2);
       pGeometry->SetVertexStride(sizeof(float) * 5);

       bool success = false;
       if (m_file.Open())
       {
              m_fileLength = m_file.Length();

              m_pTGABuffer = new char[m_fileLength];

              m_state = LOADING_FILE;
              success = true;
       }

       return success;
}

这里我们修改了顶点数组,在每个位置指定了纹理坐标的四个角。对Geometry类参数进行了相应的更改,即纹理坐标的数量被设置为 2,顶点字符串被设置为一个浮点数乘以 5 的大小。这会计算出我们的步幅为 20 字节,这很容易验证。我们有三个位置浮点和两个纹理坐标浮点。一个浮点数的大小是 4 个字节,所以 5 乘以 4 是 20;太好了。关于纹理坐标需要注意的重要一点是它们是“颠倒的”虽然零在顶部,一在底部是正常的,但 TGA 文件实际上是垂直翻转保存图像数据的。我们不需要查看复杂的代码来翻转图像数据或预处理文件,我们只需在这里反转纹理坐标。本书中的所有纹理都是 TGAs,所以这是一个可以接受的方法,但是如果你决定使用其他图像格式,这是一个你需要注意的问题。

然后我们有了一个新的代码块,它打开我们的文件,检索它的长度,并分配一个足够大的字节数组来存储它的全部内容。然后我们的状态变量被设置为LOADING_FILE;我们将看看在Update方法中的重要性,如清单 5-48 所示。

清单 6-48。 第五章任务::更新( )

void Chapter5Task::Update()
{
       switch (m_state)
       {
       case LOADING_FILE:
       {
              void* pCurrentDataPos =
                     static_cast<char*>(m_pTGABuffer) + (sizeof(char) * m_readBytes);

              size_t bytesRead = 0;
              m_file.Read(pCurrentDataPos, 512 * 1024, bytesRead);

              m_readBytes += bytesRead;
              if (m_readBytes == m_fileLength)
              {
                     m_state = CREATE_TEXTURE;
              }
       }

       break;

       case CREATE_TEXTURE:
       {
              Framework::TGAFile tgaFile(m_pTGABuffer);

              Framework::Texture::Header textureHeader;
              textureHeader.m_height = tgaFile.GetHeight();
              textureHeader.m_width = tgaFile.GetWidth();
              textureHeader.m_bytesPerPixel = 4;
              textureHeader.m_dataSize =
                     textureHeader.m_height *
                     textureHeader.m_width *
                     textureHeader.m_bytesPerPixel;

              m_texture.SetData(textureHeader, tgaFile.GetImageData());

              m_pRenderer->AddShader(&m_textureShader);
              m_pRenderer->AddTexture(&m_texture);

              m_textureShader.SetTexture(&m_texture);

              m_pRenderer->AddRenderable(&m_renderable);

              m_state = RUNNING;
       }
       break;
       };
}

我们在Update中拥有的是一个基本的状态机。状态机是一种代码结构,它指定对象中操作的当前阶段。我们的Task有三种状态:LOADING_FILECREATE_TEXTURERUNNING。以下过程显示了状态是如何变化的。

  1. LOADING_FILE状态 每次以 512 千字节的块将test.tga文件读入分配的内存缓冲区。它通过将已经读取的字节数偏移到m_pTGABuffer来计算要读入的当前位置。File::Read为每个调用传递它读入所提供的缓冲区的字节数,我们把它加到m_readBytes的值中。一旦读取的字节数与文件的大小匹配,我们就可以满意地完成并进入下一个状态CREATE_TEXTURE
  2. CREATE_TEXTURE状态 获取读取的文件并从中创建一个TGAFile的实例。然后我们用来自tgaFile的数据创建一个Texture::Header对象,并用它来初始化m_texture和来自tgaFile的图像数据。
  3. 纹理和着色器然后被添加到Renderer,这将确保它们被正确初始化。在我们切换到RUNNING状态 之前,纹理也被添加到渲染四边形的着色器中,最后可渲染的被添加到Renderer中。

在我们进入下一章之前,我想向你展示给几何图形添加纹理可以实现什么(见图 5-3 )。

9781430258308_Fig05-03.jpg

图 5-3 。有纹理的四边形

渲染文本是一个复杂的话题,我们不会在本书中详细讨论,但是通过将文本嵌入到纹理中,我们可以将文字添加到我们的游戏引擎中。对于我们之前拥有的同一个简单的矩形,纹理为我们提供了一种为玩家提供更多细节和数据的方法。

摘要

我们并没有涵盖到这一步所需的每一行代码;相反,我把重点放在对我们试图完成的任务很重要的主要方法上。我建议您看一下本章附带的示例代码,构建它,并在调试器中使用断点来找出所有内容是如何组合在一起的。

我想重申一下写游戏引擎的好处。我们刚刚讨论的许多代码都很难完成。将这些封装成可重用代码的好处是,您再也不用编写这些代码了。从Chapter5Task类中可以清楚地看到,我们现在可以相对容易地将几何、纹理和着色器添加到未来的应用中,这将提高我们的工作效率,这正是我们将要做的。

这本书的第一部分现在已经完成,我们已经研究了视频游戏从历史到今天的发展,并且已经开始编写代码,我们希望用这些代码来影响它的未来。在下一节中,我们将开始看代码,这些代码将塑造我们将要构建的游戏 Droid Runner 的游戏性。