# 游戏HUD(Game HUD) 在本章中,我们将为游戏创建一个HUD(Heads-Up Display,平视显示器)。换句话说,就是一组用于在三维场景上,随时显示相关信息的二维图形和文本。本例中将创建一个简单的HUD,这可为我们展现一些显示信息的基本技术。 当你查阅本章的源代码时,还将发现我们对源代码做了一些小的重构,特别是`Renderer`类,以便为HUD渲染做好准备。 ## 文本渲染 创建HUD所要做的第一件事是渲染文本。为了实现它,我们将储存字符的纹理映射到一个方形中,该方形将被分割为一组表示每个字符的片段。之后,我们将使用该纹理在屏幕上绘制文本。所以首先创建含有所有字母的纹理,这项工作可以使用很多软件来做,例如[CBFG](http://www.codehead.co.uk/cbfg/)、[F2IBuilder](http://sourceforge.net/projects/f2ibuilder/)等等。本例使用Codehead的位图字体生成器(Codehead’s Bitmap Font Generator,CBFG)。 CBFG允许你配置很多选项,如纹理大小、字体类型、要使用的抗锯齿等等。下图是本例将用来生成纹理文件的配置。在本章中,我们将假设文本编码为ISO-8859-1,如果你需要处理其他的字符集,则需要稍微修改代码。 ![CBFG配置](_static/12/CBG.png) 当设置好CBFG的所有选项后,可以将其导出为多种图片格式。现在我们将其导出为BMP文件,然后再转换为PNG文件,以便将其作为纹理加载。当转换为PNG格式时,我们也可以将黑色背景设置为透明,也就是说,我们将黑色设为Alpha值等于0(可以使用GIMP这样的工具来实现)。最终你会看到类似下图所示的结果。 ![字体纹理](_static/12/font_texture.png) 如你所见,图像中的所有字符都以行和列的形式排列。在本例中,图像由15列和17行字符组成。通过使用特定字符的编号,我们可以计算其对应储存在图像中的行和列。所在列的计算方法为:$列数 = 字符编号 \space mod \space 列总数$,其中$mod$是取余运算符,所在行的计算方法为:$所在行 = 字符编号 / 行总数$。在本例中我们将整数除以整数,以便忽略小数部分。 我们将创建一个名为`TextItem`的新类,它将储存渲染文本所需的图元。这是一个不考虑多行文本的简化实现,但是它能在HUD中显示文本信息。下列代码是该类的声明与构造函数: ```java package org.lwjglb.engine; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; import org.lwjglb.engine.graph.Material; import org.lwjglb.engine.graph.Mesh; import org.lwjglb.engine.graph.Texture; public class TextItem extends GameItem { private static final float ZPOS = 0.0f; private static final int VERTICES_PER_QUAD = 4; private String text; private final int numCols; private final int numRows; public TextItem(String text, String fontFileName, int numCols, int numRows) throws Exception { super(); this.text = text; this.numCols = numCols; this.numRows = numRows; Texture texture = new Texture(fontFileName); this.setMesh(buildMesh(texture, numCols, numRows)); } ``` 这个类继承了`GameItem`类,这是因为本例希望改变在屏幕上文本的位置,也可能需要缩放和旋转它。构造函数接收要渲染的文本和用于渲染的纹理的文件和相关数据(储存图像的文件及行列数)。 在构造函数中,我们加载纹理图像文件,并调用一个方法来创建一个`Mesh`实例为文本建模。让我们看到`buildMesh`方法: ```java private Mesh buildMesh(Texture texture, int numCols, int numRows) { byte[] chars = text.getBytes(Charset.forName("ISO-8859-1")); int numChars = chars.length; List positions = new ArrayList<>(); List textCoords = new ArrayList<>(); float[] normals = new float[0]; List indices = new ArrayList<>(); float tileWidth = (float)texture.getWidth() / (float)numCols; float tileHeight = (float)texture.getHeight() / (float)numRows; ``` 代码创建了用于储存Mesh的位置、纹理坐标、法线和索引的数据结构。现在我们不使用光照,因此法线数列是空的。我们要做的是构造一组字符片段,每个字符片段代表一个字符。我们还需要根据每个片段对应的字符来分配对应的纹理坐标。下图展现了文本矩形和字符片段的关系: ![文本矩形](_static/12/text_quad.png) 因此,对于每个字符,我们需要创建由两个三角形构成的字符片段,这两个三角形可以用四个顶点(V1、V2、V3和V4)定义。第一个三角形(左下角的那个)的索引为(0, 1, 2),而第二个三角形(右上角的那个)的索引为(3, 0, 2)。纹理坐标是基于与纹理图像中每个字符相关的行列计算的,纹理坐标的范围为[0, 1],所以我们只需要将当前行或当前列除以总行数和总列数就可以获得V1的坐标。对于其他顶点,我们只需要适当加上行宽或列宽就可以得到对应坐标。 下述的循环语句块创建了与渲染文本矩形相关的所有顶点、纹理坐标和索引。 ```java for(int i=0; i(); buildTexture(); } ``` 首先要处理非拉丁字符问题。给定字符集和字体,我们将构建一个包含所有可渲染字符的`String`。 ```java private String getAllAvailableChars(String charsetName) { CharsetEncoder ce = Charset.forName(charsetName).newEncoder(); StringBuilder result = new StringBuilder(); for (char c = 0; c < Character.MAX_VALUE; c++) { if (ce.canEncode(c)) { result.append(c); } } return result.toString(); } ``` 让我们来看看实际创建纹理的`buildTexture`方法: ```java private void buildTexture() throws Exception { // 使用FontMetrics获取每个字符的信息 BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); Graphics2D g2D = img.createGraphics(); g2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2D.setFont(font); FontMetrics fontMetrics = g2D.getFontMetrics(); String allChars = getAllAvailableChars(charSetName); this.width = 0; this.height = fontMetrics.getHeight(); for (char c : allChars.toCharArray()) { // 获取每个字符的大小,并更新图像的大小 CharInfo charInfo = new CharInfo(width, fontMetrics.charWidth(c)); charMap.put(c, charInfo); width += charInfo.getWidth() + CHAR_PADDING; } g2D.dispose(); ``` 我们首先通过创建临时图像来获得`FontMetrics`,然后遍历包含所有可用字符的`String`,并在`FontMetrics`的帮助下获取每个字符的宽度。我们将这些信息储存在一个`charMap`映射上,以字符作为映射的键。这样,我们就确定了纹理图像的大小(图像的高度等于所有字符的最大高度,而宽度等于所有字符的宽度总和)。`ChatSet`是一个内部类,它储存有关字符的信息(它的宽度和它在纹理图像中的起点)。 ```java public static class CharInfo { private final int startX; private final int width; public CharInfo(int startX, int width) { this.startX = startX; this.width = width; } public int getStartX() { return startX; } public int getWidth() { return width; } } ``` 然后,我们将创建一个储存所有可用字符的图像,只需在`BufferedImage`上绘制字符串即可。 ```java // 创建与字符集相关的图像 img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); g2D = img.createGraphics(); g2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2D.setFont(font); fontMetrics = g2D.getFontMetrics(); g2D.setColor(Color.WHITE); int startX = 0; for (char c : allChars.toCharArray()) { CharInfo charInfo = charMap.get(c); g2D.drawString("" + c, startX, fontMetrics.getAscent()); startX += charInfo.getWidth() + CHAR_PADDING; } g2D.dispose(); ``` 我们正在生成一个包含所有字符的单行图像(可能不满足纹理大小应该为二的幂的前提,但是它仍适用于大多数现代显卡。在任何情况下,你都可以通过增加额外的空白来解决这个问题)。你也可以查看生成的图像,在上述代码之后,添加下述的一行代码: ```java ImageIO.write(img, IMAGE_FORMAT, new java.io.File("Temp.png")); ``` 图像将被写入一个临时文件。该文件将包含一个在白色背景下,使用抗锯齿绘制所有可用字符的长条。 ![字体纹理](_static/12/texture_font.png) 最后只需要从该图像创建一个`Texture`实例,我们只需使用PNG格式(这就是`Texture`类所期望的)转储图像字节。 ```java ByteBuffer buf = null; try ( ByteArrayOutputStream out = new ByteArrayOutputStream()) { ImageIO.write(img, IMAGE_FORMAT, out); out.flush(); byte[] data = out.toByteArray(); buf = ByteBuffer.allocateDirect(data.length); buf.put(data, 0, data.length); buf.flip(); } texture = new Texture(buf); } ``` 你可能注意到,我们已经稍微修改了`Texture`类,使其具有一个接收`ByteBuffer`的构造函数。其中这个新的构造函数使用`stbi_load_from_memory`加载图片。现在我们只需更改`TextItem`类,以便在其构造函数中接收`FontTexture`实例。 ```java public TextItem(String text, FontTexture fontTexture) throws Exception { super(); this.text = text; this.fontTexture = fontTexture; setMesh(buildMesh()); } ``` 只需要在设置片段坐标和纹理坐标时稍稍修改`buildMesh`方法,下述代码是其中一个顶点的示例: ```java float startx = 0; for(int i=0; i