Saltar al contenido →

Técnicas fundamentales para optimizar draw calls (3): instancing

En esta última parte describimos e implementamos en OpenGL la otra técnica fundamental para optimizar draw calls: GPU-instancing. Si quieres ver cómo implementé batching mira la entrada anterior.

Recordemos que la idea de instancing es dibujar distintas instancias en una misma draw call que usan la misma malla pero a las que se aplica transformaciones diferentes. En nuestro caso, estas transformaciones son una posición y un color diferente por instancia.

A diferencia del batching, instancing requiere que los objetos que se dibujan en la misma draw call tengan la misma malla, es decir, los mismos vértices (e índices, si los usamos).

Hay al menos dos formas de implementar instancing:

  • Usando arrays de uniforms en los shaders que indexamos con un índice especial denominado gl_InstanceID.
  • Usando instanced arrays.

Vamos a ver las dos formas y las ventajas e inconvenientes de cada una.

Implementación con arrays de uniforms

La idea es la siguiente: en el shader de vértice, declaramos un array de uniforms por cada propiedad que queramos cambiar para la instancia. En nuestro caso, declararíamos arrays de uniforms para la transformación de modelo y el color:

#version 330 core

layout (location = 0) in vec4 position;

uniform mat4 model[100];
uniform vec3 color[100];

out vec3 outColor;

void main()
{
   outColor = color[gl_InstanceID];
   gl_Position = model[gl_InstanceID] * position;
}

En este caso he declarado un array que nos permitirá dibujar 100 instancias con sus 100 posiciones y colores. Fíjate que para indexar este array estamos usando un índice de instancia denominado gl_InstanceID. Este índice nos los proporciona automáticamente OpenGL cuando usamos una llamada a una función especial, como vamos a ver en breve. Luego, desde la aplicación, asignaríamos valores a estos uniforms para cada objeto a dibujar:

void RunSpriteRendererSystem()
{
	int i = 0;
	glUseProgram(instancedSpriteShaderProgram);

	for (const auto& sprRenderer : spriteRenderers)
	{
		auto entity = gameEntities[sprRenderer.entity];
		auto transform = transforms[entity.transform];

		Matrix4x4 model = glm::mat4(1.0f);
		model = glm::translate(model, transform.position);
		model = glm::rotate(model, glm::radians(transform.rotation), Vector3(0.0, 0.0, 1.0));
		model = glm::scale(model, transform.scale);

		glUniformMatrix4fv(glGetUniformLocation(instancedSpriteShaderProgram, ("model[" + std::to_string(i) + "]").c_str()), 1, GL_FALSE, glm::value_ptr(model));
		glUniform3fv(glGetUniformLocation(instancedSpriteShaderProgram, ("color[" + std::to_string(i) + "]").c_str()), 1, glm::value_ptr(sprRenderer.color));
			++i;
	}

	glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0, spriteRenderers.size());
}

Si has leído las partes anteriores este código te resultará familiar. Básicamente recorremos la lista de componentes sprite renderers y calculamos la matriz de modelo y obtenemos el color, y se lo pasamos a sus respectivos arrays de uniforms. Por último, llamamos a la función glDrawElementsInstanced, que es la que genera y da valor incremental al índice gl_InstanceID del shader de vértice, especificando en el último parámetro de esta función cuántas instancias queremos dibujar.

El principal problema de implementar instancing de esta manera es que estamos bastante limitados en cuanto al número de instancias que se pueden dibujar, ya que el tamaño del array de uniforms no puede superar un determinado valor, que en mi caso por ejemplo rondaba los 200. Para superar esta limitación, podemos usar el otro esquema que describo a continuación.

Implementación con instanced arrays

Normalmente, cuando definimos un atributo de vértice, dicho atributo se indexa utilizando el array de índices. Es decir, por cada invocación del shader de vértice para un vértice diferente del mismo objeto, el shader de vértice indexa un valor diferente de atributo, el que le corresponde a dicho vértice.

Los instanced arrays nos permiten definir atributos de vértice que realmente no cambian a nivel de vértice, sino a nivel de objeto, o dicho con más propiedad, a nivel de instancia.

Primero veamos cómo se definiría el shader de vértice:

#version 330 core

layout (location = 0) in vec4 position;
layout (location = 1) in mat4 model;
layout (location = 5) in vec3 color;

out vec3 outColor;

void main()
{
   outColor = color;
   gl_Position = model * position;
}

Ahora la matriz de modelo y el color lo pasamos como atributos de vértice y ya no es necesario utilizar el índice gl_InstanceID, ya que el vértice recibirá la matriz de modelo y el color apropiado de la instancia. Dado que el máximo tamaño para un atributo es de 4 floats, la matriz de modelo ocupa 4 posiciones, una por cada fila (o columna, según quieras verlo).

Por tanto ahora, desde la aplicación, lo que hacemos es lo siguiente:

void RunSpriteRendererSystem()
{
        GenerateInstancedVBO();
	glUseProgram(instancedSpriteShaderProgram);
	glDrawElementsInstanced(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0, spriteRenderers.size());
}

, donde GenerateInstancedVBO es la encargada de crear los datos que necesita OpenGL para saber cómo enviar a cada instancia una matriz de modelo y un color diferente. Vamos a ver esta función:

void GenerateInstancedVBO()
{
	glBindVertexArray(vertexArrayObject);

	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementBufferObject);
	glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

	glBindBuffer(GL_ARRAY_BUFFER, vertexBufferObject);
	glBufferData(GL_ARRAY_BUFFER, sizeof(verticesPos), verticesPos, GL_STATIC_DRAW);

	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), 0);

	glGenBuffers(1, &instancedVertexBufferPositionObject);
	glBindBuffer(GL_ARRAY_BUFFER, instancedVertexBufferPositionObject);
	for (int i = 0; i < 4; ++i)
	{
		glEnableVertexAttribArray(1 + i);
		glVertexAttribPointer(1 + i, 4, GL_FLOAT, GL_FALSE, sizeof(Matrix4x4), (const void*)(sizeof(float) * i * 4));
		glVertexAttribDivisor(1 + i, 1);
	}

	glGenBuffers(1, &instancedVertexBufferColorObject);
	glBindBuffer(GL_ARRAY_BUFFER, instancedVertexBufferColorObject);
	glEnableVertexAttribArray(5);
	glVertexAttribPointer(5, 3, GL_FLOAT, GL_FALSE, sizeof(Vector3), 0);
	glVertexAttribDivisor(5, 1);
	
        // Rellenar información
        // ...
}

Como es habitual, después de hacer binding del VAO, rellenamos el EBO (Element Buffer Object) y el VBO (Vertex Buffer Object) con los índices y las posiciones de los quads, y estas últimas irán a la posición (location) 0 de los atributos del shader de vértice.

Luego, creamos instancedVertexBufferPositionObject, que será el que almacene las matrices de modelo de cada instancia. Como he comentado antes, tenemos que crear el atributo en 4 posiciones diferentes y consecutivas porque el máximo que se permite por location es 4 floats.

Por último, creamos un VBO que almacenará los colores por instancia.

La clave de todo está en la función glVertexAttribDivisor. Esta función es la que se encarga de informar de que este atributo no es un atributo de vértice, sino un atributo de instancia. En realidad, lo que estamos especificando en el segundo parámetro de esta función es un divisor que se usará en el cálculo del índice que indexa el atributo. Si no especificamos nada, este divisor vale 0 por defecto, y esto señaliza a OpenGL que al atributo debe usar el índice del EBO para indexar el atributo, por lo que se accederá a un valor diferente del atributo por cada vértice. Cuando vale 1, el número de la instancia(1)El número de instancia empieza en 0 y se va incrementando por cada instancia dibujada. se divide entre 1, y esto es el que se usa como índice para acceder al valor del atributo. Por tanto, todos los shaders de vértices para los que el valor de instancia sea 0 accederán al valor almacenado en la posición 0 del buffer; todos los shaders de vértices para los que el valor de instancia sea 1 accederán al valor del atributo almacenado en la posición 1 del buffer, y así sucesivamente.

Si por ejemplo el divisor fuera 2, todos los shaders de vértices para los que los valores de instancia sean 0 o 1 accederán a la posición 0 del buffer. Es decir, si el divisor fuera 2, dos instancias consecutivas compartirían los mismos atributos.

Lo que queda es ver el código que rellena los datos de estos VBOs:

void GenerateInstancedVBO()
{
	// ...
        
        Matrix4x4 modelArray[numInstances];
	Vector3 colorArray[numInstances];

	int index = 0;
	for (const auto& sprRenderer : spriteRenderers)
	{
		auto entity = gameEntities[sprRenderer.entity];
		auto transform = transforms[entity.transform];

		Matrix4x4 model = glm::mat4(1.0f);
		model = glm::translate(model, transform.position);
		model = glm::rotate(model, glm::radians(transform.rotation), Vector3(0.0, 0.0, 1.0));
		model = glm::scale(model, transform.scale);

		modelArray[index] = model;
		colorArray[index] = sprRenderer.color;
		++index;
	}	

	glBindBuffer(GL_ARRAY_BUFFER, instancedVertexBufferPositionObject);
	glBufferData(GL_ARRAY_BUFFER, numInstances * sizeof(Matrix4x4), modelArray, GL_DYNAMIC_DRAW);

	glBindBuffer(GL_ARRAY_BUFFER, instancedVertexBufferColorObject);
	glBufferData(GL_ARRAY_BUFFER, numInstances * sizeof(Vector3), colorArray, GL_DYNAMIC_DRAW);
}

Creamos dos arrays, uno para cada atributo de instancia, y los vamos rellenando con las matrices de modelo y los colores de cada componente sprite renderer. Al final, le pasamos estos arrays a los VBOs.

Y esto es todo. Mientras que con esta implementación podemos dibujar muchas más instancias que con el esquema anterior, estamos ocupando posiciones (locations) de atributos de vértice, que ya de por sí no son muy numerosas(2)El número de locations para atributos de vértice suele ser de 16. Parece mucho, pero fíjate que una sola matriz ocupa 4 locations..

A continuación puedes ver una captura de Renderdoc en la que efectivamente se comprueba que todos los quads se están dibujando en una sola draw call:

En esta captura de Renderdoc se muestra cómo todos los quads se dibujan en una sola draw call
Todos los quads se dibujan en una sola draw call

Si quieres descargar el código y probarlo por ti mismo, puedes mirar el repositorio del proyecto. Basta con que localices las variables instancing y batching y las pongas a true y false, respectivamente.

Notas

Notas
1 El número de instancia empieza en 0 y se va incrementando por cada instancia dibujada.
2 El número de locations para atributos de vértice suele ser de 16. Parece mucho, pero fíjate que una sola matriz ocupa 4 locations.

Publicado en Programación

Comentarios

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *