在第一部分中我们了解了定位和单独的GUI部分的规格。而现在我们可以开始在屏幕上渲染它们。本部分比前两部分短,因为我能够分享的内容也就只有这些了。

  本系列文章分为以下章节:

  第1部分——定位

  第2部分——控制逻辑

  第3部分——渲染

  这一次,你将需要为主要的渲染准备一个API,但这并不是GUI设计的组成部分。最后,你不需要使用任何复杂的图像API,你可以基于自己最喜欢的语言(Java,C#等等)去使用基元和位图。然而我接下来要描述的内容将假设你使用的是某种图像API。我的样本将使用OpenGL和GLSL作,但可能也会为了更加清楚而改成DirectX。

  在渲染你的GUI时你拥有2个选择。首先你可以在你的场景上基于每一帧将所有内容渲染成一个几何图形。其次你可以将GUI渲染成一个纹理,然后将这一纹理与你的场景结合在一起。在大多数情况下,因为要考虑结合部分,所以这种方法往往比较慢。而这两种选择的共同之处便是都能够渲染你的系统元素。

  基本渲染

  为了保持内容的简单性,我们先从简单的方法开始,即每个元素进行分开渲染。如果你拥有许多元素的话,这并不是一种性能友好型方法,但在当前却是可行的。加上在用于主要菜单而非实际游戏的静态GUI中,它同样也是一种有效的解决方法。你可能会注意到性能工具中的警示,即表示你渲染了太多小型基元。如果你的帧数足够高,你便不需要在意像能量消耗等内容,你可以让它们顺其自然。能量消耗更有可能成为手机设备的一大问题,因此在这些设备中电池使用寿命非常重要。较少的绘制调用可能更加便宜,且比较不会消耗你的电池;并且你的设备也不会变得过烫。

  在现代API中,渲染内容的最佳方式便是使用着色器。它们提供了非常出色的用户控制—-你可以将纹理与颜色融合在一起,使用掩饰纹理进行摹制等等。你可以使用一个着色器去处理每一种类型的元素。

  以下的着色器样本便是基于GLSL进行编写。它们使用了较早的符号版本,因为这能够与OpenGL ES 2.0兼容(注:市场上的几乎所有设备都在使用这种API)。这一顶点着色器将假设你已经将你的几何图形转化成屏幕空间。

  attribute vec3 POSITION;

  attribute vec2 TEXCOORD0;

  varying vec2 vTexCoord;

  void main()

  {

  gl_Position = vec4(POSITION.xyz, 1.0);

  vTexCoord = TEXCOORD0;

  }

  在像素着色器中,我抽取了一个纹理并使用简单的组合等式将其与颜色组合在一起。基于这种方法,你可以创造不同颜色的元素,并将一些灰度图当成图像掩膜进行使用。

  uniform sampler2D guiElementTexture;

  uniform vec4 guiElementColor;

  varying vec2 vTexCoord;

  void main()

  {

  vec4 texColor = texture2D(guiElementTexture, vTexCoord);

  vec4 finalColor = (vec4(guiElementColor.rgb, 1) * guiElementColor.a);

  finalColor += (vec4(texColor.rgb, 1) * (1.0 – guiElementColor.a));

  finalColor.a = texColor.a;

  gl_FragColor = finalColor;

  }

  这便是你用于渲染GUI的基本元素所需要的所有内容。

  字体渲染

  关于字体我选择使用一个基本的渲染器而不是较高级的渲染器。如果你的文本充满动态(经常发生改变),那么这一解决方法可能需要更快速。渲染速度同样也取决于文本的长度。对于一些小标题,如“新游戏”,“继续”,“分数:0”,这便足够了。但在面对像教程等等长文本时,问题可能便会出现。如果你在每一帧拥有超过100个绘制调用,你的帧率可能会出现明显的下降。这很难说清楚,主要取决于你的硬件,驱动器优化以及其它元素。而最好的方法便是亲自去尝试。根据我的经验,有一些伴随着渲染80多个字母的丢帧,但另一方面,屏幕可能是静态的,用户可能不会注意到60帧/秒与20帧/秒之间的区别。

  对于经典的GUI元素,你必须使用能够根据每个元素进行改变的纹理。对于字体,它可能会是对你的应用造成主要杀伤力的一大元素。当然,在某些情况下,使用这种蛮力方法也是件好事。

  我们将使用所谓的纹理集进行替代。那是一个包含了所有可能纹理的单一纹理。如果你不知道我指的是什么的话你可以看看下图。当然,如果你不知道每个字母的位置,那么拥有这些纹理也是无用的。这一信息经常储存于一个包含每个字母的坐标位置的单独文件。第二个问题便是分辨率。由FreeType所提供并生成的字体是相对于矢量表示法的字体大小进行创造。通过使用字体纹理,你可能将在一个较小的分辨率中创造出非常清晰的字体,但是在较高的分辨率中却创造出较为模糊的字体。所以你需要找到纹理大小和你的字体大小间的平衡。加上你还必须考虑大多数GPU(特别是手机)拥有最大的纹理大小为4096×4096。另一方面,为字体使用这一分辨率太具杀伤力了。大多数情况下我会使用512×512或256×256去渲染大小为20的字体。这在Retina iPad上看起来更合适。

如何为小型游戏创造简单的GUI系统(3)

  font texture atlas

  我通过使用FreeType库存以及我自己的atlas creator创造了这一纹理。关于这些纹理的生成并不存在任何支持,所以你必须独自进行编写。这可能听起来很复杂,但事实却不是如此,你可以使用同一的代码去包装其它的GUI纹理。

  每个字体的字母是由一个四方形呈现出来。这一四方形只是由它的纹理坐标所创造出来。关于字体的定位和“真正的纹理坐标”都转变成了主要应用,并且每个字母都不同。我曾提到“真正的纹理坐标”。而这到底是什么呢?你拥有一个纹理字体集,而那些便是这一字体集中的字母坐标。

  在如下的代码样本中呈现出了一个强大的变量。通过缓冲一些已经生产的字体而获得加速。如果你生成了过多纹理并超过了API限制,这便会引起某些问题。例如,如果你拥有较长的文本并基于几个字体对其进行渲染,你便很容易生成无数非常小的纹理。

  //calculate “scaling”

  float sx = 2.0f / screen_width;

  float sy = 2.0f / screen_height;

  //Map texture coordinates from [0, 1] to screen space [-1, 1]

  x = MyMathUtils::MapRange(0, 1, -1, 1, x);

  y = -MyMathUtils::MapRange(0, 1, -1, 1, y); //-1 is to put origin to bottom left corner of the letter

  //wText is UTF-8 since FreeType expect this

  for (int i = 0; i < wText.GetLength(); i++)

  {

  unsigned long c = FT_Get_Char_Index(this->fontFace, wText[i]);

  FT_Error error = FT_Load_Glyph(this->fontFace, c, FT_LOAD_RENDER);

  if(error)

  {

  Logger::LogWarning(“Character %c not found.”, wText.GetCharAt(i));

  continue;

  }

  FT_GlyphSlot glyph = this->fontFace->glyph;

  //build texture name according to letter

  MyStringAnsi textureName = “Font_Renderer_Texture_”;

  textureName += this->fontFace;

  textureName += “_”;

  textureName += znak;

  if (!MyGraphics::G_TexturePool::GetInstance()->ExistTexture(textureName))

  {

  //upload new letter only if it doesnt exist yet

  //some kind of cache to improve performance

  MyGraphics::G_TexturePool::GetInstance()->AddTexture2D(textureName, //name o texture within pool

  glyph->bitmap.buffer, //buffer with raw texture data

  glyph->bitmap.width * glyph->bitmap.rows, //buffer byte size

  MyGraphics::A8, //only grayscaled texture

  glyph->bitmap.width, glyph->bitmap.rows); //width / height of texture

  }

  //calculate letter position within screen

  float x2 = x + glyph->bitmap_left * sx;

  float y2 = -y – glyph->bitmap_top * sy;

  //calculate letter size within screen

  float w = glyph->bitmap.width * sx;

  float h = glyph->bitmap.rows * sy;

  this->fontQuad->GetEffect()->SetVector4(“cornersData”, Vector4(x2, y2, w, h));

  this->fontQuad->GetEffect()->SetVector4(“fontColor”, fontColor);

  this->fontQuad->GetEffect()->SetTexture(“fontTexture”, textureName);

  this->fontQuad->Render();

  //advance start position to the next letter

  x += (glyph->advance.x >> 6) * sx;

  y += (glyph->advance.y >> 6) * sy;

  }

  改变这一代码使其能与纹理集相互协作是非常简单的事。你需要做的便是使用一个伴随着字体集内部字体坐标的额外文件。对于每个字体,这些坐标将伴随着字体的位置和大小。纹理只有一次设置机会,并会保持一样直至你改变了字体类型。然而剩下的代码也会保持一样。

  就像你在代码中所看到的,纹理位图(glyph->bitmap.buffer)是FreeType所提供的图像字符的一部分。即使你并未使用它,它也仍然会生成,只是需要花点时间。如果你的文本是静态的,你可以对它们进行“缓冲”,并在第一次运行的时候储存FreeType生成的所有内容,然后在运行时间中只是用之前创造的变量而不调用任何FreeType功能。大多数时候我会使用这种方法,这不会对字体渲染造成任何性能影响和问题。

  高级渲染

  到目前为止我们只讨论了基本的渲染。很多内容可能是你们之前就知道的,所以并没有什么好惊讶的。但很有可能在接下来的这部分中也没有可让人惊讶的内容。

  如果你拥有更多元素并希望能够尽可能对其进行渲染,那么进行分开渲染可能还不够。基于这一原因,我使用了一种“炮制”方法。我创造了一个几何图形缓存,它能够在屏幕上呈现来自所有元素的几何图形,我可以使用单一的绘制调用去绘制它们。这里所存在的问题是你需要拥有一个着色器,并且所有的元素可能是不同的。为了达到这一目的,我使用了能够处理“所有内容”的单一着色器,并且每个元素都具有统一的图形呈现。这意味着对于某些元素,你将拥有闲散的部分。你也许可以用任何内容去填补这些空间,但通常情况下它们都是0。伴随着这些闲散的部分的呈现将以“更大的”几何图形数据而告终。好好想想我所说的“更大的”这一词意味着什么。它并不是指一份巨大的开支,你的GUI仍应该基于伴随着更快的绘制的廉价内存。这便是一种平衡。

  作为几何图形我们需要传达给每个元素什么内容:

  位置—-这将被划分成2个部分。XYZ坐标以及关于元素索引的W。

  TEXCOORD0—-2组纹理坐标

  TEXCOORD1—-2组纹理坐标

  TEXCOORD2—-颜色

  TEXCOORD3—-额外1组纹理坐标和用于填补vec4的备用空间

  为什么我们需要不同组的纹理坐标的?答案很简单。我们在一个几何图形描述中创造一个完整的GUI。我们不知道哪个纹理属于哪个元素,并且还加上我们拥有源自片段着色器的有限纹理组。如果你将它们两两放置在一起,你便最终只能获得一个解决方法。是的,我们从单独的纹理为每个“炮制”元素创造了另外一个纹理集架构。通过我们从元素中所发现的内容,我们知道自己可以拥有更多纹理。这也是我们为何要在一个图形描述中拥有多个纹理坐标的原因。第一组是用于默认的纹理,而第二组是用于“徘徊的”纹理,接下来是用于点击的纹理等等。你也可以选择自己的描述。

  在一个顶点着色器中我们根据元素的当前状态选择了正确的纹理坐标,并将坐标发送到一个片段着色器。当前的元素状态是源自整数数组中的主要应用,在那里每个数字都相当于一个特定的状态,就像-1便代表一个看不见的元素(将不被渲染)。我们并不会每一帧都传输这一数据,只会在一个元素的状态发生改变时进行传输。然后我们将为“炮制”元素更新所有状态。我将最大数值限制在每个单一绘制调用64,你也可以根据自己的情况进行增加或减少(但如果选择增加的话你就要小心了,因为你可能会超过GPU统一规格的限制)。索引至这一数组被当成是位置中的W组件。

  我们可以在如下的代码中看到完整的顶点着色器和片段着色器:

  //Vertex buffer content

  attribute vec4 POSITION; //pos (xyz), index (w)

  attribute vec4 TEXCOORD0; //T0 (xy), T1 (zw)

  attribute vec4 TEXCOORD1; //T2 (xy), T3 (zw)

  attribute vec4 TEXCOORD2; //color

  attribute vec4 TEXCOORD3; //T4 (xy), unused (zw)

  //User provided input

  uniform int stateIndex[64]; //64 = max number of elements baked in one buffer

  //Output

  varying vec2 vTexCoord;

  varying vec4 vColor;

  void main()

  {

  gl_Position = vec4(POSITION.xyz, 1.0);

  int index = stateIndex[int(POSITION.w)];

  if (index == -1) //not visible

  {

  gl_Position = vec4(0,0,0,0);

  index = 0;

  }

  if (index == 0) vTexCoord = TEXCOORD0.xy;

  if (index == 1) vTexCoord = TEXCOORD0.zw;

  if (index == 2) vTexCoord = TEXCOORD1.xy;

  if (index == 3) vTexCoord = TEXCOORD1.zw;

  if (index == 4) vTexCoord = TEXCOORD3.xy;

  vColor = TEXCOORD2;

  }

  注:如果我用一个条件结构替换这一代吗,ES版本的GLSL优化程序将会脱离我的代码,它也将停止运行。所以这是唯一适合我的解决方法。

  结论

  渲染GUI并不是件困难的事。如果你熟悉渲染的基本理念,并且知道API是如何运行的,你便能够轻松地渲染所有内容。你需要谨慎地进行文本渲染,因为如果你选择了错误的方法,你将遭遇到巨大的瓶颈。