3D Game Development with LWJGL 3 第四章:渲染

在本章节中,我们将了解OpenGL渲染场景的过程。如果你习惯使用基于固定管线(fixed-function)的旧版OpenGL,在学完本章节后也许会感到惊讶,为什么它会如此复杂,并认为在屏幕上绘制一个简单地图形并不需要这么多概念和代码。事实上这么做会更简单更灵活。现代OpenGL允许一次考虑一个问题,故在管理代码与进程时显得更加合乎逻辑。

这一系列结束3D绘制并显示到2D屏幕的步骤称为图形管线(graphics pipeline)。第一版OpenGL采用了称为固定管线的模型。该模型在渲染过程中使用了一组定义了一系列固定操作的步骤,程序员受制于每个步骤中的可用函数(function)集。因此,渲染结果与操作会受到API自身的限制(例如:“设置雾气”或“添加光照”,这些函数方法的实现(implement)是固定的,并且不能被改变)。

图形管线由以下步骤组成:
图形管线
OpenGL 2.0引入了可编程管线(programmable pipeline)的概念。在此模型中,可以使用一组称为着色器(shaders)的具体方案,来控制或编程这些组成图形管线的不同步骤。下图描述了OpenGL可编程管线的简化版本:

在这里插入图片描述
渲染以一列顶点用顶点缓冲区(Vertex Buffers)的形式的输入为开始。顶点是用于描述2D或3D空间中的点的数据结构,通过指定X,Y,Z坐标,可以描述一个3D空间中的点。顶点缓冲区是另一种数据结构,它使用顶点数组,打包了所有需要渲染的顶点,使图形管线中的着色器能够使用该信息。

这些顶点由主要用于计算每个顶点在屏幕空间的投影坐标的顶点着色器来处理,该着色器也能够生成其他与颜色或纹理相关的输出,但是主要用于将顶点投射到屏幕空间中,及生成点。

几何处理阶段包含顶点着色器将顶点以三角形的形式,按照顶点存储的顺序,并使用不同的模型分组来进行转换。使用三角形的原因在于它类似于显卡的基本工作单元,是一个简单的几何形状,可以组合与转换,用以构建复杂的3D场景。此阶段还可以使用特定的着色器对顶点进行分组。

光栅化阶段剪辑上一阶段生成的三角形,并将它们转换为像素大小的片段。

这些片段在片段处理阶段中由片段着色器使用,以生成像素,为它们分配写入帧缓冲区的最终颜色。

务必记住3D图形卡旨在将上述所有操作并行化。输入的数据可以并行处理,以便生成最终场景。

现在来使用基于 ANSI C的GLSL语言(OpenGL Shading Language)编写第一个着色器程序。首先在resources目录下创建一个名为“vertex.vs”的文件,并写入如下代码:

#version 330

layout (location=0) in vec3 position;

void main()
{
    
    
    gl_Position = vec4(position, 1.0);
}

第一行表示所使用的GLSL语言的版本。下表表示GLSL版本与OpenGL版本之间的关系(维基百科:https://en.wikipedia.org/wiki/OpenGL_Shading_Language#Versions):

GLS Version OpenGL Version Shader Preprocessor
1.10.59 2.0 #version 110
1.20.8 2.1 #version 120
1.30.10 3.0 #version 130
1.40.08 3.1 #version 140
1.50.11 3.2 #version 150
3.30.6 3.3 #version 330
4.00.9 4.0 #version 400
4.10.6 4.1 #version 410
4.20.11 4.2 #version 420
4.30.8 4.3 #version 430
4.40 4.4 #version 440
4.50 4.5 #version 450

第二行指定了该着色器的输入格式。OpenGL缓冲区中的数据可以是我们想要的任何数据,也就是说,语言不会强制你使用预定义的语义传递特定的数据结构。从着色器的角度来看,它期望接收包含数据的缓冲区。这个数据可以是位置,或者包含一些额外信息的位置,也可以是其他我们想要的任何东西。顶点着色器只接收浮点数组。在填充缓冲区时,会定义将被着色器处理的缓冲区区块。

因此,首先要把那些区块变成对我们有意义的东西。该案例中,从location 0开始,我们期望接收到一个由3个属性(x,y,z)组成的向量:(location=0) in vec3 position。

着色器有一个类似于其他C语言程序的main代码块。在本案例中,这块代码非常简短。它仅返回在输出变量gl_Position中接收到的位置信息,不作任何转换。现在问题来了:为什么这个有3个属性的向量会被转换到一个有4个属性的向量中(vec4)?这是因为gl_Position使用与vec4相同的坐标,它期望得到vec4格式的结果,也就是说,它期望(x,y,z,w)格式的数据,其中w表示一个额外的维度。在后续章节中,会看到很多操作需要基于向量和矩阵,因此需要添加一个额外的维度。如果没有这个维度,其中一些操作无法相互结合。例如无法将旋转与平移操作相结合(若你想了解更多相关内容,这个额外维度允许我们结合仿射与线性变换。在《3D Math Primer for Graphics and Game Development》by Fletcher Dunn and Ian Parberry一书中可了解更多,)。

创建一个名为“fragment.fs”的文件(扩展名的含义为Fragment Shader),来编写我们的第一个片段着色器:

#version 330

out vec4 fragColor;

void main()
{
    
    
    fragColor = vec4(0.0, 0.5, 0.5, 1.0);
}

这段代码的结构与顶点着色器非常相似。在本案例中我们为每个片段设置一个固定的颜色。

输出变量在第二行定义,并设置为vec4 fragColor。

现在着色器创建完成,按照下述步骤可以使用我们创建的着色器:

1.创建一个OpenGL程序
2.加载顶点与片段着色器的代码文件
3.对于每个着色器,创建一个新的着色器程序并指定它的类型(顶点、片段)
4.编译着色器
5.将着色器附加到程序
6.链接程序

最终着色器将被加载至显卡中,我们可以通过引用标识符(程序标识符)来使用它。

package org.lwjglb.engine.graph;

import static org.lwjgl.opengl.GL20.*;

public class ShaderProgram {
    
    

    private final int programId;

    private int vertexShaderId;

    private int fragmentShaderId;

    public ShaderProgram() throws Exception {
    
    
        programId = glCreateProgram();
        if (programId == 0) {
    
    
            throw new Exception("Could not create Shader");
        }
    }

	// 顶点着色器
    public void createVertexShader(String shaderCode) throws Exception {
    
    
        vertexShaderId = createShader(shaderCode, GL_VERTEX_SHADER);
    }

	// 片段着色器
    public void createFragmentShader(String shaderCode) throws Exception {
    
    
        fragmentShaderId = createShader(shaderCode, GL_FRAGMENT_SHADER);
    }

	// 创建着色器
    protected int createShader(String shaderCode, int shaderType) throws Exception {
    
    
        int shaderId = glCreateShader(shaderType);
        if (shaderId == 0) {
    
    
            throw new Exception("Error creating shader. Type: " + shaderType);
        }
		// 获取着色器代码,并编译 
        glShaderSource(shaderId, shaderCode);
        glCompileShader(shaderId);

        if (glGetShaderi(shaderId, GL_COMPILE_STATUS) == 0) {
    
    
            throw new Exception("Error compiling Shader code: " + glGetShaderInfoLog(shaderId, 1024));
        }
		// 将着色器附加至OpenGL程序中
        glAttachShader(programId, shaderId);

        return shaderId;
    }

	// 链接
    public void link() throws Exception {
    
    
    	// 链接程序
        glLinkProgram(programId);
        if (glGetProgrami(programId, GL_LINK_STATUS) == 0) {
    
    
            throw new Exception("Error linking Shader code: " + glGetProgramInfoLog(programId, 1024));
        }
		// 释放
        if (vertexShaderId != 0) {
    
    
            glDetachShader(programId, vertexShaderId);
        }
        if (fragmentShaderId != 0) {
    
    
            glDetachShader(programId, fragmentShaderId);
        }
		// 验证
        glValidateProgram(programId);
        if (glGetProgrami(programId, GL_VALIDATE_STATUS) == 0) {
    
    
            System.err.println("Warning validating Shader code: " + glGetProgramInfoLog(programId, 1024));
        }

    }

    public void bind() {
    
    
        glUseProgram(programId);
    }

    public void unbind() {
    
    
        glUseProgram(0);
    }

    public void cleanup() {
    
    
        unbind();
        if (programId != 0) {
    
    
            glDeleteProgram(programId);
        }
    }
}

该ShaderProgram类的构造方法中创建了一个新的OpenGL程序,并提供了添加顶点和片段着色器的方法。这些着色器被编译并附加至OpenGL程序中。当全部的着色器被附加时,调用链接方法,链接所有的代码并验证操作是否全部正确。

当着色器程序链接完成时,编译后的顶点与片段着色器可以被释放(调用glDetachShader方法)。

关于验证,通过调用glValidateProgram方法来实现。该方法主要用于调试目的,在生产环境中必须将其删除。该方法对着色程序对象进行正确性验证,检查着色器可执行程序能够在当前OpenGL状态下执行(原文:This method tries to validate if the shader is correct given the current OpenGL state,直译表达不明确,通过查阅资料改译为上述语句)。这意味着,在某些情况下,即使着色器正确,验证也可能会失败,因为当前状态不够完整,无法运行着色器(某些数据可能尚未被加载)。因此我们只需打印错误信息,而不是抛出异常将程序判定为失败。

该ShaderProgram类还提供了用于激活程序进行渲染的方法(bind),和停止渲染的方法(unbind)。最后提供一个cleanup方法,当所有资源不再需要被使用的时候,释放这些资源。

在IGameLogic接口中也需要添加该方法:

void cleanup();

该方法在游戏循环结束时调用,故修改GameEngine类的run方法:

@Override
public void run() {
    
    
    try {
    
    
        init();
        gameLoop();
    } catch (Exception excp) {
    
    
        excp.printStackTrace();
    } finally {
    
    
        cleanup();
    }
}

protected void cleanup() {
    
    
    gameLogic.cleanup();                
}

现在,可以在Renderer类的init方法中,使用着色器来显示一个三角形。首先,创建着色器程序:

private ShaderProgram shaderProgram;

public void init() throws Exception {
    
    
    shaderProgram = new ShaderProgram();
    shaderProgram.createVertexShader(Utils.loadResource("/vertex.vs"));
    shaderProgram.createFragmentShader(Utils.loadResource("/fragment.fs"));
    shaderProgram.link();
}

在此之前,创建一个工具类,用于提供在classpath中检索文件内容的方法。该方法用来检索着色器的代码内容。

现在我们可以使用一个浮点数组来定义一个三角形。创建一个浮点数组,该数组将定义三角形的顶点。如你所见,这个数组没有结构,一样的,OpenGL并不会知道该数组的结构,它只是一串浮点数字:

float[] vertices = new float[]{
    
    
     0.0f,  0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f
};

下图描绘了坐标系中该三角形的样子:
三角形
有了坐标,就需要将它存放进显卡,并告诉OpenGL相应的数据结构。现在来介绍两个重要的概念:顶点数组对象 (VAO) 和顶点缓冲区对象 (VBO)。如果不太理解下面的代码,请记住,在最终我们需要做的是将所需要的绘制的模型对象的数据送至显存中。当数据完成存储时,会得到一个标识符,在稍后绘制时会引用它。

先从顶点缓冲区对象(VBO)开始。VBO只是存储在显存中的一个内存缓冲区,用于存储顶点,将在此传输用于建立三角形模型的数组。和之前说的一样,OpenGL并不知道关于数据结构的一切信息。事实上,它不仅可以包含坐标,还可以保存其他信息,如纹理,颜色等。

顶点数组对象 (VAO) 是包含一个或多个 VBO的对象,通常被称为属性列表。每个属性列表可以保存一种类型的数据,如位置、颜色、纹理等待。每个列表中可以随意存储任意想要存储的数据。

VAO类似一个封装器,用来把将要被存入显卡的数据的定义进行分组。在创建VAO时,会获得一个标识符。在使用该标识符时,通过在创建过程中指定的定义,来渲染该标识符及其包含的元素。

现在回到代码。首先必须做的是将浮点数组存储至FloatBuffer。其主要原因是必须连接基于C语言的OpenGL库。因此需要将浮点数组转为可由库管理的形式。

FloatBuffer verticesBuffer = MemoryUtil.memAllocFloat(vertices.length);
verticesBuffer.put(vertices).flip();

使用MemoryUtil类在堆外内存(off-heap memory)中创建缓冲区,以便OpenGL库可以访问它。存储数据(使用 put 方法)后,需要使用flip方法将缓冲区的位置重置为 0 (也就是说,完成了对缓冲区的写入)。请记住,Java 对象在称为堆的空间中分配。堆是 JVM 进程内存中保留的一大块内存,本机代码无法访问存储在堆中的内存(机器允许Java通过JNI调用本机代码,但是也不能访问堆内存)。在 Java 代码和本机代码之间共享内存数据的唯一方法就是直接在 Java 中分配内存。

对于旧版本的LWJGL,有几点是需要强调的。可能你已经注意到,没有使用BufferUtils工具类来创建缓冲区,而使用的MemoryUtil类。因为实际上BufferUtils效率不高,并只保持了向后兼容性。LWJGL3提出了两种缓冲区管理方法用于替代:

  • 自动管理缓冲区(Auto managed buffers):由垃圾回收(Garbage Collector,GC)自动回收的缓冲区。这些缓冲区主要用于短期操作,或传输到 GPU 且不需要存在于进程内存中的数据。由org.lwjgl.system.MemoryStack类实现。
  • 手动管理缓冲区(Manually managed buffers):在此情况下,当完成时,需要小心地释放缓冲区。这些缓冲区用于长时间操作的数据或大量数据。由MemoryUtil类实现。

可以在此查阅相关信息:https://blog.lwjgl.org/memory-management-in-lwjgl-3/

本章案例中,数据发送至GPU以便可以使用自动管理的缓冲区。但是,之后可能将保存大量数据,故可能需要手动管理缓冲区。所以这就是使用MemoryUtil类,在finally代码块释放内存的原因。在下一章节中将学习如何使用自动管理缓冲区。

创建VAO,并绑定它:

vaoId = glGenVertexArrays();
glBindVertexArray(vaoId);

然后创建VBO,绑定它并放入数据:

vboId = glGenBuffers();
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glBufferData(GL_ARRAY_BUFFER, verticesBuffer, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);

下面是最重要的部分:我们需要定义数据的结构,并将其存储在VAO的属性列表中:

glVertexAttribPointer(0, 3, GL_FLOAT, false, 0, 0);

参数含义:

  • index:指定着色器期望此数据的位置
  • size:指定每个顶点中的成员个数(从1到4),本案例使用3D坐标,故为3
  • type:指定数组中每个成员的类型,在本案例中为浮点
  • normalized:指定值是否需要被常规化
  • stride:指定连续泛型顶点属性之间的字节偏移量(在后面会解释)
  • offset:指定缓冲区中第一个成员的偏移量

VBO完成后需要解绑它和VAO(绑定至0)

// Unbind the VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);

// Unbind the VAO
glBindVertexArray(0);

在完成此操作之后,必须释放由FloatBuffer分配的堆外内存,由手动调用memFree方法完成,因为Java垃圾回收不会清理堆外分配。

if (verticesBuffer != null) {
    
    
    MemoryUtil.memFree(verticesBuffer);
}

这就是init方法中所包含的全部代码。数据已经在显卡中,随时可以使用。只需要修改渲染方法,在游戏循环中使用它的每个渲染步骤。

public void render(Window window) {
    
    
    clear();

    if (window.isResized()) {
    
    
        glViewport(0, 0, window.getWidth(), window.getHeight());
        window.setResized(false);
    }

    shaderProgram.bind();

    // Bind to the VAO
    glBindVertexArray(vaoId);

    // Draw the vertices
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // Restore state
    glBindVertexArray(0);

    shaderProgram.unbind();
}

如你所见,只需要清空窗口,绑定着色器程序,绑定VAO,绘制存储在与VAO关联的VBO中的顶点,最后恢复原状,即可。

在Renderer类中还需要添加一个cleanup方法,可释放获取的资源。

public void cleanup() {
    
    
    if (shaderProgram != null) {
    
    
        shaderProgram.cleanup();
    }

    glDisableVertexAttribArray(0);

    // Delete the VBO
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glDeleteBuffers(vboId);

    // Delete the VAO
    glBindVertexArray(0);
    glDeleteVertexArrays(vaoId);
}

按照上述全部步骤编写程序,你将看到如下结果:
三角形demo
你可能会认为,这不会进入前十名的游戏名单,当然如此。你可能也觉得,上述这么多步骤,只为了画一个无聊的三角形。但请记住,我们正在介绍关键概念,想完成更多复杂的事情,需要这些基础的结构来准备。请耐心阅读后续章节。

本章代码

官方github代码已clone至gitee
https://gitee.com/CrimsonHu/lwjglbook/tree/master/chapter04

猜你喜欢

转载自blog.csdn.net/m0_37942304/article/details/108057585