纹理(Textures)

创建一个三维立方体

在本章中,我们将学习如何在渲染中加载纹理并使用它们。为了讲解与纹理相关的所有概念,我们将把此前章节中使用的正方形更改为三维立方体。为了绘制一个立方体,我们只需要正确地定义一个立方体的坐标,就能使用现有代码正确地绘制它。

为了绘制立方体,我们只需要定义八个顶点。

立方体坐标

因此,它的坐标数组将是这样的:

  1. float[] positions = new float[] {
  2. // VO
  3. -0.5f, 0.5f, 0.5f,
  4. // V1
  5. -0.5f, -0.5f, 0.5f,
  6. // V2
  7. 0.5f, -0.5f, 0.5f,
  8. // V3
  9. 0.5f, 0.5f, 0.5f,
  10. // V4
  11. -0.5f, 0.5f, -0.5f,
  12. // V5
  13. 0.5f, 0.5f, -0.5f,
  14. // V6
  15. -0.5f, -0.5f, -0.5f,
  16. // V7
  17. 0.5f, -0.5f, -0.5f,
  18. };

当然,由于我们多了4个顶点,我们需要更改颜色数组,目前仅重复前四项的值。

  1. float[] colours = new float[]{
  2. 0.5f, 0.0f, 0.0f,
  3. 0.0f, 0.5f, 0.0f,
  4. 0.0f, 0.0f, 0.5f,
  5. 0.0f, 0.5f, 0.5f,
  6. 0.5f, 0.0f, 0.0f,
  7. 0.0f, 0.5f, 0.0f,
  8. 0.0f, 0.0f, 0.5f,
  9. 0.0f, 0.5f, 0.5f,
  10. };

最后,由于立方体是由六个面构成的,需要绘制十二个三角形(每个面两个),因此我们需要修改索引数组。记住三角形必须按逆时针顺序定义,如果你直接去定义三角形,很容易犯错。一定要将你想定义的面摆在你的面前,确认顶点并以逆时针顺序绘制三角形。

  1. int[] indices = new int[] {
  2. // 前面
  3. 0, 1, 3, 3, 1, 2,
  4. // 上面
  5. 4, 0, 3, 5, 4, 3,
  6. // 右面
  7. 3, 2, 7, 5, 3, 7,
  8. // 左面
  9. 6, 1, 0, 6, 0, 4,
  10. // 下面
  11. 2, 1, 6, 2, 6, 7,
  12. // 后面
  13. 7, 6, 4, 7, 4, 5,
  14. };

为了更好观察立方体,我们将修改DummyGame类中旋转模型的代码,使模型沿着三个轴旋转。

  1. // 更新旋转角
  2. float rotation = gameItem.getRotation().x + 1.5f;
  3. if ( rotation > 360 ) {
  4. rotation = 0;
  5. }
  6. gameItem.setRotation(rotation, rotation, rotation);

这就完了,现在能够显示一个旋转的三维立方体了,你可以编译和运行示例代码,会得到如下所示的东西。

没有开启深度测试的立方体

这个立方体有些奇怪,有些面没被正确地绘制,这发生了什么?立方体之所以出现这个现象,是因为组成立方体的三角形是以一种随机顺序绘制的。事实上距离较远的像素应该在距离较近的像素之前绘制,而不是现在这样。为了修复它,我们必须启用深度测试(Depth Test)。

这将在Window类的init方法中去做:

  1. glEnable(GL_DEPTH_TEST);

现在立方体被正确地渲染了!

开启深度测试的立方体

如果你看了本章该小节的代码,你可能会看到Mesh类做了一下小规模的调整,VBO的ID现在被储存在一个List中,以便于迭代它们。

为立方体添加纹理

现在我们将把纹理应用到立方体上。纹理(Texture)是用来绘制某个模型的像素颜色的图像,可以认为纹理是包在三维模型上的皮肤。你要做的是将纹理图像中的点分配给模型中的顶点,这样做OpenGL就能根据纹理图像计算其他像素的颜色。

纹理映射

纹理图像不必与模型同样大小,它可以变大或变小。如果要处理的像素不能映射到纹理中的指定点,OpenGL将推断颜色。可在创建纹理时控制如何进行颜色推断。

因此,为了将纹理应用到模型上,我们必须做的是将纹理坐标分配给每个顶点。纹理坐标系有些不同于模型坐标系。首先,我们的纹理是二维纹理,所以坐标只有X和Y两个量。此外,原点是图像的左上角,X或Y的最大值都是1。

纹理坐标系

我们如何将纹理坐标与位置坐标联系起来呢?答案很简单,就像传递颜色信息,我们创建了一个VBO,为每个顶点储存其纹理坐标。

让我们开始修改代码,以便在三维立方体上使用纹理吧。首先是加载将被用作纹理的图像。对此在LWJGL的早期版本中,通常使用Slick2D库。在撰写本文时,该库似乎与LWJGL 3不兼容,因此我们需要使用另一种方法。我们将使用LWJGL为stb库提供的封装。为了使用它,首先需要在本地的pom.xml文件中声明依赖。

  1. <dependency>
  2. <groupId>org.lwjgl</groupId>
  3. <artifactId>lwjgl-stb</artifactId>
  4. <version>${lwjgl.version}</version>
  5. </dependency>
  6. [...]
  7. <dependency>
  8. <groupId>org.lwjgl</groupId>
  9. <artifactId>lwjgl-stb</artifactId>
  10. <version>${lwjgl.version}</version>
  11. <classifier>${native.target}</classifier>
  12. <scope>runtime</scope>
  13. </dependency>

在一些教程中,你可能看到首先要做的事是调用glEnable(GL_TEXTURE_2D)来启用OpenGL环境中的纹理。如果使用固定管线这是对的,但我们使用GLSL着色器,因此不再需要了。

现在我们将创建一个新的Texture类,它将执行加载纹理所必须的步骤。首先,我们需要将图像载入到ByteBuffer中,代码如下:

  1. private static int loadTexture(String fileName) throws Exception {
  2. int width;
  3. int height;
  4. ByteBuffer buf;
  5. // 加载纹理文件
  6. try (MemoryStack stack = MemoryStack.stackPush()) {
  7. IntBuffer w = stack.mallocInt(1);
  8. IntBuffer h = stack.mallocInt(1);
  9. IntBuffer channels = stack.mallocInt(1);
  10. buf = stbi_load(fileName, w, h, channels, 4);
  11. if (buf == null) {
  12. throw new Exception("Image file [" + fileName + "] not loaded: " + stbi_failure_reason());
  13. }
  14. /* 获得图像的高度与宽度 */
  15. width = w.get();
  16. height = h.get();
  17. }
  18. [... 接下来还有更多代码 ...]

首先我们要为库分配IntBuffer,以返回图像大小与通道数。然后,我们调用stbi_load方法将图像加载到ByteBuffer中,该方法需要如下参数:

  • filePath:文件的绝对路径。stb库是本地的,不知道关于CLASSPATH的任何内容。因此,我们将使用常规的文件系统路径。
  • width:图像宽度,获取的图像宽度将被写入其中。
  • height:图像高度,获取的图像高度将被写入其中。
  • channels:图像通道。
  • desired_channels:所需的图像通道,我们传入4(RGBA)。

一件关于OpenGL的重要事项,由于历史原因,要求纹理图像的大小(每个轴的像素数)必须是二的指数(2, 4, 8, 16, ….)。一些驱动解除了这种限制,但最好还是保持以免出现问题。

下一步是将纹理上传到显存中。首先需要创建一个新的纹理ID,与该纹理相关的操作都要使用该ID,因此我们需要绑定它。

  1. // 创建一个新的OpenGL纹理
  2. int textureId = glGenTextures();
  3. // 绑定纹理
  4. glBindTexture(GL_TEXTURE_2D, textureId);

然后需要告诉OpenGL如何解包RGBA字节,由于每个分量只有一个字节大小,所以我们需要添加以下代码:

  1. glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

最后我们可以上传纹理数据:

  1. glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height,
  2. 0, GL_RGBA, GL_UNSIGNED_BYTE, buf);

glTexImage2D的参数如下所示:

  • target: 指定目标纹理(纹理类型),本例中是GL_TEXTURE_2D
  • level: 指定纹理细节的等级。0级是基本图像等级,第n级是第n个多级渐远纹理的图像,之后再谈论这个问题。
  • internal format: 指定纹理中颜色分量的数量。
  • width: 指定纹理图像的宽度。
  • height: 指定纹理图像的高度。
  • border: 此值必须为0。
  • format: 指定像素数据的格式,现在为RGBA。
  • type: 指定像素数据的类型。现在,我们使用的是无符号字节。
  • data: 储存数据的缓冲区。

在一些代码中,你可能会发现在调用glTexImage2D方法前设置了一些过滤参数。过滤是指在缩放时如何绘制图像,以及如何插值像素。如果未设置这些参数,纹理将不会显示。因此,在glTexImage2D方法调用之前,会看到以下代码:

  1. glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
  2. glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

这些参数基本上在说,当绘制一个像素时,如果没有直接一对一地关联到纹理坐标,它将选择最近的纹理坐标点。

到目前为止,我们不会设置这些参数。相反,我们将生成一个多级渐远纹理(Mipmap)。多级渐远纹理是由高细节纹理生成的逐级降低分辨率的纹理集合。当我们的物体缩放时,就将自动使用低分辨率的图像。

为了生成多级渐远纹理,只需要编写以下代码(目前我们把它放在glTextImage2D方法调用之后):

  1. glGenerateMipmap(GL_TEXTURE_2D);

最后,我们可以释放原始图像数据本身的内存:

  1. stbi_image_free(buf);

就这样,我们已经成功地加载了纹理,现在需要使用它。正如此前所说,我们需要把纹理坐标作为另一个VBO。因此,我们要修改Mesh类以接收储存纹理坐标的浮点数组,而不是颜色(我们可以同时有颜色和纹理,但为了简化它,我们将删除颜色),构造函数现在如下所示:

  1. public Mesh(float[] positions, float[] textCoords, int[] indices,
  2. Texture texture)

纹理坐标VBO与颜色VBO创建的方式相同。唯一的区别是它每个顶点属性只有两个分量而不是三个:

  1. vboId = glGenBuffers();
  2. vboIdList.add(vboId);
  3. textCoordsBuffer = MemoryUtil.memAllocFloat(textCoords.length);
  4. textCoordsBuffer.put(textCoords).flip();
  5. glBindBuffer(GL_ARRAY_BUFFER, vboId);
  6. glBufferData(GL_ARRAY_BUFFER, textCoordsBuffer, GL_STATIC_DRAW);
  7. glEnableVertexAttribArray(1);
  8. glVertexAttribPointer(1, 2, GL_FLOAT, false, 0, 0);

现在我们需要在着色器中使用纹理。在顶点着色器中,我们修改了第二个输入参数,因为现在它是一个vec2(也顺便更改了名称)。顶点着色器就像此前一样,仅将纹理坐标传给片元着色器。

  1. #version 330
  2. layout (location=0) in vec3 position;
  3. layout (location=1) in vec2 texCoord;
  4. out vec2 outTexCoord;
  5. uniform mat4 worldMatrix;
  6. uniform mat4 projectionMatrix;
  7. void main()
  8. {
  9. gl_Position = projectionMatrix * worldMatrix * vec4(position, 1.0);
  10. outTexCoord = texCoord;
  11. }

在片元着色器中,我们使用那些纹理坐标来设置像素颜色:

  1. #version 330
  2. in vec2 outTexCoord;
  3. out vec4 fragColor;
  4. uniform sampler2D texture_sampler;
  5. void main()
  6. {
  7. fragColor = texture(texture_sampler, outTexCoord);
  8. }

在分析代码之前,我们先理清一些概念。显卡有几个空间或槽来储存纹理,每一个空间被称为纹理单元(Texture Unit)。当使用纹理时,我们必须设置想用的纹理。如你所见,我们有一个名为texture_sampler的新Uniform,该Uniform的类型是sampler2D,并储存有我们希望使用的纹理单元的值。

main函数中,我们使用名为texture的纹理采样函数,该函数有两个参数:取样器(Sampler)和纹理坐标,并返回正确的颜色。取样器Uniform允许使用多重纹理(Multi-texture),不过现在不是讨论这个话题的时候,但是我们会在稍后再尝试添加。

因此,在ShaderProgram类中,我们将创建一个新的方法,允许为整数型Uniform设置值:

  1. public void setUniform(String uniformName, int value) {
  2. glUniform1i(uniforms.get(uniformName), value);
  3. }

Renderer类的init方法中,我们将创建一个新的Uniform:

  1. shaderProgram.createUniform("texture_sampler");

此外,在Renderer类的render方法中,我们将Uniform的值设置为0(我们现在不使用多个纹理,所以只使用单元0)。

  1. shaderProgram.setUniform("texture_sampler", 0);

最好,我们只需修改Mesh类的render方法就可以使用纹理。在方法起始处,添加以下几行代码:

  1. // 激活第一个纹理单元
  2. glActiveTexture(GL_TEXTURE0);
  3. // 绑定纹理
  4. glBindTexture(GL_TEXTURE_2D, texture.getId());

我们已经将texture.getId()所获得的纹理ID绑定到纹理单元0上。

我们刚刚修改了代码来支持纹理,现在需要为三维立方体设置纹理坐标,纹理图像文件是这样的:

立方体纹理

在我们的三维模型中,共有八个顶点。我们首先定义正面每个顶点的纹理坐标。

立方体纹理的正面

顶点 纹理坐标
V0 (0.0, 0.0)
V1 (0.0, 0.5)
V2 (0.5, 0.5)
V3 (0.5, 0.0)

然后,定义顶面的纹理映射。

正方体纹理的顶面

顶点 纹理坐标
V4 (0.0, 0.5)
V5 (0.5, 0.5)
V0 (0.0, 1.0)
V3 (0.5, 1.0)

如你所见,有一个问题,我们需要为同一个顶点(V0和V3)设置不同的纹理坐标。怎么样才能解决这个问题呢?解决这一问题的唯一方法是重复一些顶点并关联不同的纹理坐标。对于顶面,我们需要重复四个顶点并为它们分配正确的纹理坐标。

因为前面、后面和侧面都使用相同的纹理,所以我们不需要重复这些顶点。在源码中有完整的定义,但是我们需要从8个点上升到20个点了。最终的结果就像这样。

有纹理的立方体

在接下来的章节中,我们将学习如何加载由3D建模工具生成的模型,这样我们就不需要手动定义顶点和纹理坐标了(顺便一提,对于更复杂的模型,手动定义是不存在的)。

透明纹理简介

如你所见,当加载图像时,我们检索了四个RGBA组件,包括透明度等级。但如果加载一个透明的纹理,可能看不到任何东西。为了支持透明度,我们需要通过以下代码启用混合(Blend):

  1. glEnable(GL_BLEND);

但仅启用混合,透明效果仍然不会显示,我们还需要指示OpenGL如何进行混合。这是通过调用glBlendFunc方法完成的:

  1. glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

你可以查看此处有关可使用的不同功能的详细说明。

即使启用了混合并设置了功能,也可能看不到正确的透明效果。其原因是深度测试,当使用深度值丢弃片元时,我们可能将具有透明度的片元与背景混合,而不是与它们后面的片元混合,这将得到错误的渲染结果。为了解决该问题,我们需要先绘制不透明物体,然后按深度递减顺序绘制具有透明度的物体(应先绘制较远物体)。