# 游戏HUD(Game HUD) 本章中我们将为游戏实现一个HUD(平视显示器)。换句话说,就是在三维场景上用一组二维图形和文本显示相关信息。我们将创建一个简单的HUD,接下来将说明一些如何显示这些信息的基本方法。 当你查看本章的源代码时,还将看到我们重构了一些代码,特别是`Renderer`类,以便为HUD渲染做好准备。 ## 文本渲染 创建HUD所要做的第一件事是渲染文本。为了实现它,我们将把包含字母字符的纹理的纹理映射到一个矩形中,该矩形将被分割为一组表示各个字符的片段。之后,我们将使用该纹理绘制文本。所以第一步是创建含有所有字符的纹理,你可以使用很多程序来做,例如[CBFG](http://www.codehead.co.uk/cbfg/)、[F2IBuilder](http://sourceforge.net/projects/f2ibuilder/)等等。现在我们使用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行。通过 As you can see, the image has all the characters displayed in rows and columns. In this case the image is composed by 15 columns and 17 rows. By using the character code of a specific letter we can calculate the row and the column that is enclosed in the image. The column can be calculated as follows: $$column = code \space mod \space numberOfColumns$$. Where $$mod$$ is the module operator. The row can be calculated as follows: $$row = code / numberOfCols$$, in this case we will do a integer by integer operation so we can ignore the decimal part. 我们将创建一个名为`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.setFont(font); FontMetrics fontMetrics = g2D.getFontMetrics(); String allChars = getAllAvailableChars(charSetName); this.width = 0; this.height = 0; for (char c : allChars.toCharArray()) { // 获取每个字符的大小,并更新图像大小 CharInfo charInfo = new CharInfo(width, fontMetrics.charWidth(c)); charMap.put(c, charInfo); width += charInfo.getWidth(); height = Math.max(height, fontMetrics.getHeight()); } g2D.dispose(); ``` 我们首先通过创建创建临时图像来获得`FontMetrics`,然后遍历包含所有可用字符的`String`,并在`FontMetrics`的帮助下获取字体的宽度。我们把这些信息储存在一个`charMap`上,以字符作为`Map`的键。这样,我们就确定了纹理图像的大小(图像的高度等于所有字符的最大高度,而宽度等于所有字符的宽度总和)。 `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); g2D.drawString(allChars, 0, fontMetrics.getAscent()); g2D.dispose(); ``` 我们正在生成一个包含所有字符的单行图像(可能不满足纹理大小应该为二的幂的前提,但是它仍适用于大多数现代显卡。在任何情况下,你都可以通过增加额外的空白来解决这个问题)。你甚至可以看到生成的图像,在代码之后,添加这样的一行代码: ```java ImageIO.write(img, IMAGE_FORMAT, new java.io.File("Temp.png")); ``` 图像将被写入一个临时文件。该文件将包含一长条可用的所有字符,在白色背景下启用反走样绘制。 ![字体纹理](_static/12/texture_font.png) 最后只需要从那个图像创建一个`Texture`实例,我们只需使用PNG格式转储图像字节(这就是`Texture`类所需要的)。 ```java // 将图像转储到字节缓冲区 InputStream is; try ( ByteArrayOutputStream out = new ByteArrayOutputStream()) { ImageIO.write(img, IMAGE_FORMAT, out); out.flush(); is = new ByteArrayInputStream(out.toByteArray()); } texture = new Texture(is); } ``` 你可能注意到,我们已经稍微修改了`Texture`类,以便可以使用一个接收`InputStream`的构造函数。现在我们只需要修改`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