Saltar al contenido →

Técnicas fundamentales para optimizar draw calls (2): batching

En esta segunda parte veremos cómo podemos implementar batching en OpenGL con el objetivo de optimizar el número de draw calls.

En la primera parte expliqué que había dos técnicas principales para reducir el número de draw calls: batching e instancing. Además expuse cómo se podía implementar el dibujado de un conjunto de quads en OpenGL siguiendo el paradigma de Entity Component System (ECS). Te recomiendo que le eches un vistazo antes de leer esta entrada para poder seguir mejor el código y las explicaciones.

Ahora pasaremos a ver cómo podríamos implementar batching para que todos los quads se dibujen en una sola draw call.

Fíjate que en la última entrada lo que teníamos era algo como esto para dibujar los quads:

void RunSpriteRendererSystem()
{
	for (const auto& sprRenderer : spriteRenderers)
	{
	       //Preparar datos para dibujado de quad
               //...

                //Dibujar quad
		glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
	}
}

Por cada componente sprite renderer, hacíamos una draw call. Con batching, la idea es agrupar todos los quads en un único objeto de forma que sólo tengamos que hacer una draw call:

void RunSpriteRendererSystem()
{
        //Preparar objeto grande a partir de quads
        BatchSprites();

	//Dibujar objeto grande
	glDrawElements(GL_TRIANGLES, totalIndices, GL_UNSIGNED_INT, 0);
}

Como puedes ver, glDrawElements ya no está dentro de un bucle, pero en lugar de usar los 6 índices necesarios para dibujar un quad, usa un número totalIndices que se habrá calculado previamente en la función BatchSprites.

La función BatchSprites está dentro del bucle de juego porque asumimos que los objetos pueden cambiar, y por tanto es necesario ejecutarla en cada frame. Esto es batching dinámico. Si supiéramos que un conjunto de objetos no se va a mover, podríamos hacer el batching antes del bucle principal, y esto es lo que se conoce como batching estático.

Antes de ver esta función en detalle, conviene darse cuenta de que ya no es necesario almacenar todos los datos OpenGL para cada componente sprite renderer, puesto que ahora sólo vamos a tener un objeto que dibujar. Por ello, definimos los datos OpenGL necesarios en una función de inicialización:

GLuint vertexBufferObject;
GLuint elementBufferObject;
GLuint vertexArrayObject;

void FrancisECS::Init(int width, int height, const char* title)
{
       //Aquí inicializamos el contexto OpenGL y creamos la ventana
       //...

        glGenBuffers(1, &vertexBufferObject);
	glGenBuffers(1, &elementBufferObject);
	glGenVertexArrays(1, &vertexArrayObject);
}

Por otra parte, la función AddSpriteRendererComponent quedaría muy simple:

void FrancisECS::AddSpriteRendererComponent(EntityHandle entityHandle, const Vector3& color)
{
	SpriteRendererComponent sprRenderer;
	
	sprRenderer.entity = entityHandle;
	sprRenderer.color = color;

        spriteRenderers.push_back(sprRenderer);
	gameEntities[entityHandle].spriteRenderer = spriteRenderers.size();
}

Lo principal es almacenar el color del quad, y guardar el componente en su array así como las relaciones entre el componente y la entidad.

Ahora ya podemos pasar a la función BatchSprites, que es la clave de la entrada. Vamos a ir viéndola poco a poco:

constexpr unsigned int numVerticesInQuad = 4;
constexpr unsigned int numVertexPositionComponents = 4;
constexpr unsigned int numVertexColorComponents = 3;
constexpr unsigned int totalVertexComponents = numVertexPositionComponents + numVertexColorComponents;
constexpr unsigned long long totalComponentsInMatrix = numVerticesInQuad * totalVertexComponents;

void BatchSprites()
{
	//Create the aggregate vertices array
	totalVerticesInfo = totalComponentsInMatrix * spriteRenderers.size();
	verticesBatch = new float[totalVerticesInfo];

	//Create the aggregate indices array
	totalIndices = 6 * spriteRenderers.size();
	indicesBatch = new unsigned int[totalIndices];

        //...
}

Lo primero es crear los arrays que almacenarán los datos de los vértices y de los índices. Lo principal aquí es calcular bien el tamaño de dichos arrays. Para los índices es fácil, puesto que sabemos que un quad require 6 índices para poder dibujarse, por lo que simplemente tenemos que multiplicar este número por el número de componentes sprite renderer que tengamos.

El tamaño del array de posición se calcula teniendo en cuenta el número de vértices de los que se compone un quad, el número de componentes de los que se compone un vértice (en este caso, 4 componentes para la posición y 3 para el color), y el número de componentes sprite renderer que queremos dibujar.

De esta forma, si queremos dibujar un solo quad, necesitaríamos 7 elementos (4 posiciones y 3 colores) por cada uno de los 4 vértices, por lo que el tamaño del array sería de 28 floats (112 bytes). Si tuviéramos que dibujar 2 quads, sería el doble, es decir, 56 floats (224 bytes).

Lo siguiente que hay que hacer es rellenar estos arrays de forma adecuada:

void BatchSprites()
{        
        //...
 
        unsigned int sprIndex = 0;
	for (const auto& sprRenderer : spriteRenderers)
	{
		AddIndicesToBatch(indicesBatch, sprIndex);

		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);

		auto transformedVerticesPositions = glm::value_ptr(model * basePositionMatrix);
		auto sprColor = glm::value_ptr(sprRenderer.color);

				AddVerticesInfoToBatch(verticesBatch, transformedVerticesPositions, sprColor, sprIndex);
		
		++sprIndex;
	}
}

Tenemos que ir recorriendo todos los componentes sprite renderers, que constituyen las mallas simples, para formar la malla compleja que las agrupa a todas. Lo primero que hacemos es rellenar el array indicesBatch en la función AddIndicesToBatch. Seguidamente calculamos la posición transformada del quad, porque esa nueva posición es la que vamos a guardar en el array verticesBatch. Para ello, simplemente calculamos la matriz de modelo a partir de la posición, rotación y escala del quad y la pre-multiplicamos (porque la librería matemática GLM asume que trabajamos con vectores columna) por la posición inicial base de los quads, definida así:

constexpr Matrix4x4 basePositionMatrix =
{
	0.1f, 0.1f, 0.0f, 1.0f,
	0.1f, -0.1f, 0.0f, 1.0f,
	-0.1f, -0.1f, 0.0f, 1.0f,
	-0.1f, 0.1f, 0.0f, 1.0f
};

La función glm::value_ptr simplemente nos devuelve la matriz como un array de floats. Una vez tenemos la posición transformada de los vértices y el color del quad, le pasamos toda esta información a la función AddVerticesInfoToBatch para formar el array verticesBatch.

Nos quedan por definir las dos funciones principales: AddIndicesToBatch y AddVerticesInfoToBatch. Vamos a empezar con la primera, que es más simple. Recuerda que para dibujar un quad necesitamos 6 índices:

constexpr unsigned int indices[] = {
	0, 1, 2,
	0, 2, 3
};

Si queremos dibujar dos quads, aplicando la misma lógica, el array indices debería estar definido así:

constexpr unsigned int indices[] = {
	0, 1, 2,  //índices para el primer quad
	0, 2, 3,

        4, 5, 6,  // índices para el segundo quad
        4, 6, 7
};

Como puedes ver, los índices para el segundo quad son los mismos que para el primero sumando la constante 4, y así seguiría sucesivamente: para el tercero sumaríamos 8, para el cuarto 12, y para el sprIndex-quad sería sprIndex * 4. Esto es lo que hace la función, calcular los índices para el quad en la posición sprIndex:

constexpr unsigned int numIndicesPerSprite = 6;
void AddIndicesToBatch(unsigned int* indicesBatch, unsigned int sprIndex) noexcept
{
	for (int i = 0; i < numIndicesPerSprite; ++i)
	{
		indicesBatch[numIndicesPerSprite * sprIndex + i] = indices[i] + 4 * sprIndex;
	}
}

Pasemos ahora a la función AddVerticesInfoToBatch. Esta función tiene que coger los vértices transformados del quad y, para cada vértice, concatenar el color del quad.

float verticesBatch[] =
{
     //Vértices para el primer quad
     V1X, V1Y, V1Z, V1W, R, G, B,
     V2X, V2Y, V2Z, V2W, R, G, B,
     V3X, V3Y, V3Z, V3W, R, G, B,
     V4X, V4Y, V4Z, V4W, R, G, B,

     //Vértices para el segundo quad...
     //...
};

, donde R, G, B es el color del quad. Y esto habría que hacerlo para todos los quads que queremos batchear en la misma draw call. La función quedaría así:

constexpr unsigned int numVerticesInQuad = 4;
constexpr unsigned int numVertexPositionComponents = 4;
constexpr unsigned int numVertexColorComponents = 3;
constexpr unsigned int totalVertexComponents = numVertexPositionComponents + numVertexColorComponents;
constexpr unsigned long long totalComponentsInMatrix = numVerticesInQuad * totalVertexComponents;

void AddVerticesInfoToBatch(float *verticesBatch, const float* transformedVerticesPositions, const float* sprColor, unsigned int sprIndex)
{
	int counter = 0;
	for (int i = 0; i < 4; ++i)
	{
		memcpy(verticesBatch + sprIndex* totalComponentsInMatrix + counter, transformedVerticesPositions + i * numVertexPositionComponents, numVertexPositionComponents * sizeof(float));
		counter += numVertexPositionComponents;
		memcpy(verticesBatch + sprIndex* totalComponentsInMatrix + counter, sprColor, numVertexColorComponents * sizeof(float));
		counter += numVertexColorComponents;
	}
}

Definimos una variable counter que lleva el recuento de cuántos bytes se han escrito. La mejor forma de ver que efectivamente esta función hace lo que he explicado arriba es coger un boli y una hoja y hacer un ejemplo para los dos o tres primeros quads (sprIndex = 0, sprIndex = 1, etc). Por ejemplo, para el primer quad, sprIndex = 0, tendríamos lo siguiente:

  • verticesBatch[0] – verticesBatch[3]: posiciones del primer vértice
  • verticesBatch[4] – vertices[6]: colores del primer vértice
  • verticesBatch[21] – verticesBatch[24]: posiciones del cuarto vértice
  • verticesBatch[25] – verticesBatch[27]: colores del último vértice

El segundo batch (sprIndex = 1) empezaría a rellanar el array en verticesBatch[28], y repetiría el mismo esquema de arriba a partir de dicha posición.

Y por último quedaría revisitar la función RunSpriteRendererSystem, que ahora quedaría así:

void RunSpriteRendererSystem()
{
        BatchSprites();

        glBindVertexArray(vertexArrayObject);

	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementBufferObject);
	glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(unsigned int) * totalIndices, indicesBatch, GL_DYNAMIC_DRAW);

	glBindBuffer(GL_ARRAY_BUFFER, vertexBufferObject);
	glBufferData(GL_ARRAY_BUFFER, sizeof(float) * totalVerticesInfo, verticesBatch, GL_DYNAMIC_DRAW);

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

	glEnableVertexAttribArray(1);
	glVertexAttribPointer(1, numVertexColorComponents, GL_FLOAT, GL_FALSE, totalVertexComponents * sizeof(float), (void*)(numVertexPositionComponents * sizeof(float)));

	delete[] verticesBatch;
	delete[] indicesBatch;
		
	glUseProgram(defaultSpriteShaderProgram);

	glUniformMatrix4fv(glGetUniformLocation(defaultSpriteShaderProgram, "model"), 1, GL_FALSE, glm::value_ptr(glm::mat4(1.0f)));

	glDrawElements(GL_TRIANGLES, totalIndices, GL_UNSIGNED_INT, 0);
}

Lo primero es batchear los quads y a continuación se rellenan las estructuras que OpenGL necesita para saber cómo dibujar el objeto que hemos creado resultado del batching. Ahora al shader de vértice le pasamos la matriz identidad como matriz de modelo, ya que las posiciones de los vértices ya están transformadas. Y finalmente, hacemos una única draw call con todos los índices de todos los quads.

Aunque el resultado va a ser el mismo que el mostrado en la primera parte de esta entrada, lo que ocurre por debajo es totalmente diferente, como puede apreciarse en la siguiente imagen:

Captura de Renderdoc donde se muestra una sola draw call tras aplicar batching
Captura de Renderdoc en la que se muestra que sólo se produce una draw call

Efectivamente y como puede observarse en esta captura de Renderdoc, sólo se está realizando una draw call para dibujar todos esos quads. Así que… ¡objetivo cumplido!

Si quieres ver el código más tranquilamente o incluso descargarlo y probarlo tú mismo, puedes hacerlo en el repositorio del proyecto. Basta con que busques las variables booleanas instancing y batching y las pongas a false y true respectivamente.

En la siguiente y última entrada veremos la otra técnica que puede usarse para reducir el número de draw calls: instancing.

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 *