Files
lwjglbook-CN-Translation/docs/21-instanced-rendering.md
2019-04-03 14:19:34 +08:00

14 KiB
Raw Blame History

实例化渲染Instanced Rendering

很多实例

在绘制三维场景时,经常会有许多模型用相同的网格表示,但它们具有不同的变换。在此情况下,尽管它们可能是只有几个三角形的简单物体,但性能仍可能会受到影响,这背后的原因就是我们渲染它们的方式。

我们基本上是在循环中遍历所有游戏项,并对函数glDrawElements进行调用。如前几章所说的对OpenGL库的调用应该最小化。对函数glDrawElements的每次调用都会造成一定开销。这对于每个GameItem实例会反复产生开销。

在处理大量相似物体时使用一次调用渲染所有这些物体会更有效。这种技术被称为实例化渲染。为了实现同时渲染一组元素OpenGL提供了一组名为glDrawXXXInstanced的函数。在本例中,由于我们正在绘制元素,所以将使用名为glDrawElementsInstanced的函数。该函数接收与glDrawElements相同的参数,外加一个额外的参数,用于设置要绘制的实例数。

这是一个如何使用glDrawElements的示例:

glDrawElements(GL_TRIANGLES, numVertices, GL_UNSIGNED_INT, 0)

以下是实例化版本的用法:

glDrawElementsInstanced(GL_TRIANGLES, numVertices, GL_UNSIGNED_INT, 0, numInstances);

但是你现在可能想知道如何为每个实例设置不同的变换。现在在绘制每个实例之前我们将使用Uniform来传递不同的变换和实例相关的数据。在进行渲染调用之前我们需要为每项设置特定的数据。当它们开始渲染时我们如何做到这一点呢

当使用实例化渲染时在顶点着色器中我们可以使用一个输入变量来储存当前绘制实例的索引。例如使用这个内置变量我们可以传递一个包含要应用到每个实例的变换的Uniform数组并仅做一次渲染调用。

这种方法的问题是它仍然会带来太多的开销。除此之外我们能够传递的Uniform数量是有限的。因此需要使用另一种方法而不是使用Uniform数组我们将使用实例化数组。

如果你还记得前几章每个网格的数据都是由一组名为VBO的数据数组定义的。这些VBO中的数据储存在每个唯一的Mesh实例中。

VBO

使用标准的VBO在着色器中我们可以访问与每个顶点其位置、颜色、纹理等相关的数据。无论何时运行着色器输入变量都被设置为指向与每个顶点相关的指定数据。使用实例化数组我们可以设置每个实例而不是每个顶点所更改的数据。如果我们将这两种类型结合起来就可以使用常规的VBO来储存每个顶点的信息坐标纹理坐标和用VBO储存每个实例的数据如模型观察矩阵

下图展示了由三个顶点组成的网格,每个顶点定义了坐标、纹理与法线。每个元素的第一个索引是它所属的实例(蓝色)。第二个索引表示实例中的顶点位置。

网格也由两个实例VBO定义。一个用于模型观察矩阵另一个用于光照观察矩阵。当为第一个实例渲染顶点第1X个模型观察矩阵和光照观察矩阵是相同的第1个。当渲染第二个实例的顶点时将使用第二个模型观察矩阵和光照观察矩阵。

有实例属性的VBO

因此在渲染第一个实例的第一个顶点时将使用V11、T11和N11作为坐标、纹理和法线数据并使用MV1作为模型观察矩阵。当渲染同一个实例的第二个顶点时将使用V12、T12和N12作为坐标、纹理和法线数据仍使用MV1作为模型观察矩阵。在渲染第二个实例之前不会使用MV2和LV2。

为了定义每个实例数据,我们需要在顶点属性之后调用函数glVertexAttribDivisor。该函数接收两个参数:

  • index顶点属性的索引glVertexAttribPointer函数中设置的)。

  • divisor: 如果这个值为0那么在渲染时每个顶点的数据都会改变。如果将其设置为1则每渲染一个实例数据更改一次。如果它被设置为2则每渲染两个实例就会更改一次以此类推。

因此,为了设置实例的数据,我们需要在每个属性定义之后进行如下调用:

glVertexAttribDivisor(index, 1);

让我们开始修改代码库,以支持实例化渲染。第一步是创建一个名为InstancedMesh的新类,该类继承自Mesh类。该类的构造函数类似于Mesh的构造函数,但有一个额外的参数,即实例数。

在构造函数中除了依赖超类的构造函数外我们还将创建两个新的VBO一个用于模型观察矩阵另一个用于光照观察矩阵。创建模型观察矩阵的代码如下所示

modelViewVBO = glGenBuffers();
vboIdList.add(modelViewVBO);
this.modelViewBuffer = MemoryUtil.memAllocFloat(numInstances * MATRIX_SIZE_FLOATS);
glBindBuffer(GL_ARRAY_BUFFER, modelViewVBO);
int start = 5;
for (int i = 0; i < 4; i++) {
    glVertexAttribPointer(start, 4, GL_FLOAT, false, MATRIX_SIZE_BYTES, i * VECTOR4F_SIZE_BYTES);
    glVertexAttribDivisor(start, 1);
    start++;
}

我们首先要做的事是创建一个新的VBO和一个新的FloatBuffer以在其内储存数据。缓冲区的大小是用浮点数数量计算的所以它等于实例数乘以4x4矩阵的浮点数大小等于16

一旦VBO被绑定我们就开始为它定义属性。你可以看到这是在for循环中完成的,循环进行了四次。每轮循环定义一个矩阵向量。为什么不简单地为整个矩阵定义一个属性呢?原因是顶点属性不能储存超过四个浮点数。因此,我们需要把矩阵定义分为四部分。让我们重新认识一下glVertexAttribPointer的参数:

  • Index: 要定义的元素的索引。
  • Size: 该属性的分量数。在本例中,它是四个浮点数,这是可接受的最大值。
  • Type: 数据类型(在本例中为浮点型)。
  • Normalize: 是否应该归一化指定数据。
  • Stride步长: 理解这里的概念很重要,它设置了连续属性之间的字节偏移量。在本例中,我们需要将其设置为整个矩阵的字节大小。这就像一个用于包装数据的标记,从而可以在顶点或实例之间进行更改。
  • Pointer: 此属性定义应用的偏移量。在本例中,我们需要将矩阵定义拆分为四次调用。依矩阵的每个向量增加偏移量。

定义了顶点属性之后,我们需要使用相同的索引调用glVertexAttribDivisor

光照观察矩阵的定义与上述过程类似,你可以在源代码中查看它。继续进行InstancedMesh类的定义,重写方法以在渲染之前启用顶点属性(以及在渲染之后要禁用它们)是很重要的。

@Override
protected void initRender() {
    super.initRender();
    int start = 5;
    int numElements = 4 * 2;
    for (int i = 0; i < numElements; i++) {
        glEnableVertexAttribArray(start + i);
    }
}

@Override
protected void endRender() {
    int start = 5;
    int numElements = 4 * 2;
    for (int i = 0; i < numElements; i++) {
        glDisableVertexAttribArray(start + i);
    }
    super.endRender();
}

InstancedMesh类定义了一个名为renderListInstanced的公共方法,它渲染一系列的游戏项,这个方法将游戏项列表分割为大小与创建InstancedMesh所设实例数量相等的块。真正的渲染方法是renderChunkInstanced,定义如下:

private void renderChunkInstanced(List<GameItem> gameItems, boolean depthMap, Transformation transformation, Matrix4f viewMatrix, Matrix4f lightViewMatrix) {
    this.modelViewBuffer.clear();
    this.modelLightViewBuffer.clear();
    int i = 0;
    for (GameItem gameItem : gameItems) {
        Matrix4f modelMatrix = transformation.buildModelMatrix(gameItem);
        if (!depthMap) {
            Matrix4f modelViewMatrix = transformation.buildModelViewMatrix(modelMatrix, viewMatrix);
            modelViewMatrix.get(MATRIX_SIZE_FLOATS * i, modelViewBuffer);
        }
        Matrix4f modelLightViewMatrix = transformation.buildModelLightViewMatrix(modelMatrix, lightViewMatrix);
        modelLightViewMatrix.get(MATRIX_SIZE_FLOATS * i, this.modelLightViewBuffer);
        i++;
    }
    glBindBuffer(GL_ARRAY_BUFFER, modelViewVBO);
    glBufferData(GL_ARRAY_BUFFER, modelViewBuffer, GL_DYNAMIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, modelLightViewVBO);
    glBufferData(GL_ARRAY_BUFFER, modelLightViewBuffer, GL_DYNAMIC_DRAW);
    glDrawElementsInstanced(GL_TRIANGLES, getVertexCount(), GL_UNSIGNED_INT, 0, gameItems.size());
    glBindBuffer(GL_ARRAY_BUFFER, 0);
}

该方法很简单我们遍历游戏项计算模型观察矩阵和光照观察矩阵。这些矩阵被转储到它们各自的缓冲区中。这些缓冲区的内容被发送到GPU最后通过对方法glDrawElementsInstanced的调用来渲染它们。

回到着色器,我们需要修改顶点着色器以支持实例化渲染。首先我们为模型观察矩阵添加新的输入参数,这些参数将在使用实例化渲染时传递。

layout (location=5) in mat4 modelViewInstancedMatrix;
layout (location=9) in mat4 modelLightViewInstancedMatrix;

如你所见模型观察矩阵从位置5开始。由于矩阵是由一组共四个属性每个属性储存一个向量定义的所以光照观察矩阵从位置9开始。因为我们想在非实例化渲染和实例化渲染中使用同一着色器所以我们将维护模型观察矩阵和光照观察矩阵的Uniform。我们只需更改它们的名字。

uniform int isInstanced;
uniform mat4 modelViewNonInstancedMatrix;

uniform mat4 modelLightViewNonInstancedMatrix;

我们创建了另一个Uniform来指定是否使用实例化渲染。在使用实例化渲染的情况下代码非常简单我们只使用输入参数中的矩阵。

void main()
{
    vec4 initPos = vec4(0, 0, 0, 0);
    vec4 initNormal = vec4(0, 0, 0, 0);
    mat4 modelViewMatrix;
    mat4 lightViewMatrix;
    if ( isInstanced > 0 )
    {
        modelViewMatrix = modelViewInstancedMatrix;
        lightViewMatrix = modelLightViewInstancedMatrix;
        initPos = vec4(position, 1.0);
        initNormal = vec4(vertexNormal, 0.0);
    }

我们暂时不支持动画实例化渲染,以简化示例,但是该技术可以完美地用于此处。

最后,着色器像往常一样设置恰当的值。

    vec4 mvPos = modelViewMatrix * initPos;
    gl_Position = projectionMatrix * mvPos;
    outTexCoord = texCoord;
    mvVertexNormal = normalize(modelViewMatrix * initNormal).xyz;
    mvVertexPos = mvPos.xyz;
    mlightviewVertexPos = orthoProjectionMatrix * lightViewMatrix * initPos;
    outModelViewMatrix = modelViewMatrix;
}

当然,Renderer类已经被修改以支持Uniform的修改并将非实例化网格的渲染从实例化网格中分离出来。你可以查看源代码中的修改。

此外JOML的作者Kai Burjack还向源代码添加了一些优化。这些优化已经用于Transformation类,并总结为如下几条:

  • 删除冗余调用,以设置具有单位值的矩阵。
  • 使用四元数进行更有效的旋转。
  • 使用特定的旋转和平移矩阵的方法,这些方法是针对这些操作优化的。

实例化渲染

回顾粒子

在实例化渲染的支持下,我们还可以提高粒子渲染的性能。粒子就是最好的用例。

为了将实例化渲染应用到粒子上我们必须提供对纹理集的支持。这可以通过添加一个带有纹理偏移量的VBO来实现。纹理偏移量可以由两个浮点数组成的单个向量定义因此不需要像矩阵那样拆分定义。

// 纹理偏移量
glVertexAttribPointer(start, 2, GL_FLOAT, false, INSTANCE_SIZE_BYTES, strideStart);
glVertexAttribDivisor(start, 1);

但是我们将在单个VBO中设置所有实例属性而不是添加一个新的VBO。下图展示了这个概念。我们将所有属性打包在一个VBO中每个实例的值都会发生变化。

单个VBO

为了使用单个VBO我们需要修改实例中所有属性的属性大小。从上述代码中可以看到纹理偏移量的定义使用了一个名为INSTANCE_SIZE_BYTES的常量。这个常量等于两个矩阵一个用于模型观察矩阵定义另一个用于光照观察矩阵定义的字节大小再加上两个浮点数纹理偏移量的字节大小总共是136。步长也需要适当地调整。

你可以在源代码中查看修改。

Renderer类也需要修改,以实用实例化渲染粒子和支持纹理集在场景中渲染。在本例中,支持这两种类型(非实例化和实例化)的渲染是没有意义的,所以修改更简单。

粒子的顶点着色器如下所示:

#version 330
layout (location=0) in vec3 position;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertexNormal;
layout (location=5) in mat4 modelViewMatrix;
layout (location=13) in vec2 texOffset;
out vec2 outTexCoord;
uniform mat4 projectionMatrix;
uniform int numCols;
uniform int numRows;
void main()
{
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

    // 支持纹理集,更新纹理坐标
    float x = (texCoord.x / numCols + texOffset.x);
    float y = (texCoord.y / numRows + texOffset.y);
    outTexCoord = vec2(x, y);}

上述修改的效果看起来和非实例化粒子渲染时完全一样但性能更高。FPS计数器已作为选项添加到窗口标题中。你可以使用实例化渲染和非实例化渲染来看看自身的性能提升。

粒子

扩展

结合我们现在所拥有的所有基础构造,我已经基于使用高度图修改了渲染方块数据的代码,还使用了纹理集以使用不同的纹理。它还融合了粒子渲染。看起来是这样的:

使用高度图的方块

请记住这还有很大的优化空间但这本书的目的是指导你学习LWJGL和OpenGL的概念和技术。我们的目标不是创建一个完整的游戏引擎绝对不是一个体素引擎它需要不同的方法和更多的优化