Batching e instancing son técnicas para optimizar el número de draw calls. Cuando se trata de optimizar el renderizado, una de las primeras cosas que te aconsejan es que reduzcas el número de draw calls. Esto es así porque cada draw call supone una sobrecarga al tener que comunicarse la CPU con la GPU a través de un bus relativamente lento. Si por ejemplo tenemos que dibujar 1000 objetos simples en pantalla y para ello usamos 1000 draw calls, el overhead de comunicación CPU-GPU va a superar el tiempo empleado en dibujar los objetos.
Para optimizar las llamadas que se realizan hay dos técnicas fundamentales:
- Batching: consiste en reorganizar los datos de las mallas (meshes) de forma que en lugar de tener un conjunto de mallas simples tengamos una sola malla más compleja que contiene a las primeras. Este trabajo lo realiza la CPU antes de pedirle a la GPU que dibuje la malla, y puede ser un batching estático si se realiza sobre objetos que no cambian, o dinámico si los objetos pueden cambiar.
- Instancing: llamado también GPU-Instancing, consiste en enviar información de una malla a la GPU y pedirle que le aplique una transformación diferente por cada objeto (o instancia, de ahí el nombre). De esta forma, objetos que tienen la misma malla pueden dibujarse con transformaciones diferentes en una sola draw call.
El batching en general es más flexible, puesto que no es necesario que las mallas de los objetos que se quieren agrupar tengan que ser iguales. Sin embargo, requiere de un pre-procesamiento por parte de la CPU que puede llegar a ser más costoso que simplemente enviar los objetos individualmente a la GPU. Si el batching es estático, es decir, se realiza sobre objetos que no cambian a lo largo de la aplicación, no hay problema puesto que se puede realizar antes del bucle principal del juego. Pero si es dinámico, es decir, los objetos pueden cambiar (de posición, color, etc), es preciso realizarlo en cada frame, lo que puede suponer una sobrecarga perceptible.
Para comprender mejor todo esto, voy a explicar una posible implementación de estas técnicas que he realizado en OpenGL aplicado al ejemplo de dibujar unos 400 quads en pantalla, cada uno de un color y con rotación. El resultado será el siguiente:
Y aunque el resultado sea el mismo usando batching, instancing, o sin usar ninguna de ellas, lo que ocurre por debajo en cada caso es muy diferente. Quizás lo más obvio es ver la diferencia de lo que está ocurriendo cuando aplicamos alguna de estas técnicas y cuando no la aplicamos.
Como puedes ver arriba, resultado de aplicar Renderdoc al ejemplo, cuando no usamos batching ni instancing, cada quad se está dibujando en una draw call diferente. Sin embargo, al usar alguna de las técnicas, todos los quads se dibujan en una sola draw call.
Puesto que hay mucha información que absorber, he decidido dividir la entrada en tres partes. En esta primera parte, explicaré brevemente la arquitectura que he seguido para implementar el ejemplo, sin usar batching ni instancing. Esto ayudará a comprender mejor el código y las siguientes dos partes, donde explicaré la implementación del batching y del instancing respectivamente.
Arquitectura ECS
Para el diseño del programa he seguido el paradigma conocido como ECS (Entity Component System). Voy a explicarlo brevemente para que así te resulte más fácil seguir el código. ECS se compone de tres elementos:
- Entidades: identificadores únicos de los objetos del juego.
- Componentes: datos de los que se componen las entidades, como por ejemplo la posición o los objetos OpenGL (o de la API gráfica de turno) necesarios para dibujar la entidad.
- Sistemas: funciones que actúan sobre conjuntos de componentes para producir datos a partir de unas entradas.
De esta forma, los pasos para programar de acuerdo a este paradigma serían:
- Crear entidades
- Crear componentes y asignárselos a las entidades
- Crear sistemas que toman como entradas componentes, y ejecutarlos en cada iteración del bucle principal.
Si quieres profundizar más en este paradigma y comprender por qué es más adecuado en general que la clásica orientación a objetos, te recomiendo esta charla de Mike Acton, quien ahora precisamente está trabajando en Unity para intentar trasladar estos conceptos a este motor.
Dibujando quads en pantalla
Voy a pasar a explicar cómo he aplicado ECS al ejemplo de los quads que he mencionado arriba. Nuestro objetivo es dibujar un conjunto de quads, cada uno en una posición, con un color diferente mientras van rotando. De momento no vamos a usar batching ni instancing, por lo que cada quad se dibujará en una draw call.
Para toda entidad que se va a dibujar en pantalla, necesitamos asociar a dicha entidad un componente que agrupe los objetos OpenGL necesarios para que la GPU sepa cómo dibujarla. Este es el objetivo del SpriteRendererComponent que se muestra a continuación:
typedef struct
{
GLuint vertexBufferObject;
GLuint elementBufferObject;
GLuint vertexArrayObject;
Vector3 color;
EntityHandle entity;
} SpriteRendererComponent;
extern std::vector<SpriteRendererComponent> spriteRenderers;
void AddSpriteRendererComponent(EntityHandle entity, const Vector3& color = Vector3(1.0, 1.0, 1.0));
Como puedes ver, también definimos un array de esta estructura para poder almacenar un conjunto de componentes que queremos dibujar, así como una función para añadir este componente a una entidad, con un color determinado.
La información de posición, rotación y escala del objeto está en otro componente denominado TransformComponent:
typedef struct
{
Vector3 position;
decimal rotation;
Vector3 scale;
EntityHandle entity;
} TransformComponent;
extern std::vector<TransformComponent> transforms;
EntityHandle CreateGameEntity(Vector3 position = Vector3(0.0, 0.0, 0.0), decimal rotation = 0.0, Vector3 scale = Vector3(1.0, 1.0, 1.0));
Lo que une todos los componentes de una misma entidad es el EntityHandle, que en mi caso está definido como un entero de 4 bytes:
using EntityHandle = __int32;
La función CreateGameEntity es la que se encarga de crear una nueva entidad y crear un nuevo componente transform para la misma con la posición, orientación y escala de dicha entidad, tal y como se observa a continuación:
EntityHandle FrancisECS::CreateGameEntity(Vector3 position, decimal rotation, Vector3 scale)
{
static EntityHandle handle = 0;
TransformComponent transform =
{
position, rotation, scale, handle
};
SpriteRendererComponent spriteRenderer =
{
0, 0, 0, 0, 0, Vector3(0, 0, 0), handle
};
GameEntity e = {
0,
handle,
-1
};
transforms.push_back(transform);
gameEntities.push_back(e);
return handle++;
}
Cada entidad creada la almaceno en un array de entidades denominado gameEntities. Al final, como he mencionado antes, lo que permite determinar qué componentes tiene una entidad concreta es el hecho de que el identificador (la entidad en sí misma), se almacena en cada componente, tal y como muestra la siguiente figura:
En la imagen se ven cinco entidades, y todas tienen transformComponents, pero sólo tres tienen spriteRendererComponents.
La función AddSpriteRendererComponent es la que añade el componente SpriteRendererComponent a una entidad. Vamos a ir viéndola poco a poco
void FrancisECS::AddSpriteRendererComponent(EntityHandle entityHandle, const Vector3& color)
{
SpriteRendererComponent sprRenderer;
sprRenderer.entity = entityHandle;
sprRenderer.color = color;
glGenBuffers(1, &sprRenderer.vertexBufferObject);
glGenBuffers(1, &sprRenderer.elementBufferObject);
//IMPORTANT: OpenGL Core needs Vertex Array Objects to render anything
glGenVertexArrays(1, &sprRenderer.vertexArrayObject);
glBindVertexArray(sprRenderer.vertexArrayObject);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, sprRenderer.elementBufferObject);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
//...
Lo primero que hacemos es almacenar la entidad a la que se refiere este componente así como el color de la misma (aunque para este ejemplo, puesto que el color no va a cambiar, no sería necesario). Después generamos los objetos OpenGL necesarios para que dicha API sepa cómo enviar la información a la GPU. Como vamos a dibujar los vértices indexados, es decir, usando los índices de los mismos, usamos un element buffer object (EBO), que cargamos con la información de un array de floats constantes denominado indices:
constexpr unsigned int indices[] =
{
0, 1, 2,
0, 2, 3
};
Lo siguiente que tenemos que hacer es preparar la información para el vertex buffer object (VBO). Esta información incluye los atributos de cada vértice, en este caso la posición y el color. Como queremos un color uniforme para el quad, el color de cada vértice de un mismo quad va a ser el mismo. Todos los quads van a compartir una posición inicial expresada en NDC (Normalized Device Coordinates), es decir, la posición en cada eje está comprendida entre -1 y +1. Esta posición base la definimos así:
constexpr float verticesPos[] =
{
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
};
Nuestro objetivo es pasar la posición base de cada vértice concatenada por el color de dicho vértice, y esto es lo hace el siguiente código:
void FrancisECS::AddSpriteRendererComponent(EntityHandle entityHandle, const Vector3& color)
{
//...
float vertexInfo[numVerticesInQuad * (numVertexPositionComponents + numVertexColorComponents)];
int counter = 0;
auto colorArray = glm::value_ptr(sprRenderer.color);
for (int i = 0; i < numVertexPositionComponents; ++i)
{
memcpy(vertexInfo + counter,
verticesPos + i * numVertexPositionComponents, sizeof(float) * numVertexPositionComponents);
counter += numVertexPositionComponents;
memcpy(vertexInfo + counter, colorArray, sizeof(float) * numVertexColorComponents);
counter += numVertexColorComponents;
}
glBindBuffer(GL_ARRAY_BUFFER, sprRenderer.vertexBufferObject);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexInfo), vertexInfo, GL_STATIC_DRAW);
//...
Esto hace que el array vertexInfo tenga los atributos de cada vértice de la siguiente manera:
float vertexInfo[] =
{
0.1f, 0.1f, 0.0f, 1.0f, R, G, B,
0.1f, -0.1f, 0.0f, 1.0f, R, G, B,
-0.1f, -0.1f, 0.0f, 1.0f, R, G, B,
-0.1f, 0.1f, 0.0f, 1.0f, R, G, B
};
, donde R, G, B son los componentes del vector color pasado como argumento. Al final, le pasamos esta información al VBO.
Por último, especificamos los dos atributos de vértices que pasamos: la posición en el slot 0 y el color en el slot 1.
void FrancisECS::AddSpriteRendererComponent(EntityHandle entityHandle, const Vector3& color)
{
//...
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 7 * sizeof(float), 0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 7 * sizeof(float), (void*)(4 * sizeof(float)));
}
Los shaders de vértice y de fragmento son muy simples. El de vértice simplemente recibe la posición y el color del vértice como atributos, y también recibe una matriz de modelo (model matrix) que se le pasará mediante un uniform para que pueda cambiar la posición del vértice. La matriz de modelo codifica una de las muchas transformaciones que experimentan los objetos antes de ser renderizados. La salida del shader de vértice incluirá el color del vértice (para que el rasterizador lo interpole y se lo pase al shader de fragmento) y la posición transformada.
#version 330 core
layout (location = 0) in vec4 position;
layout (location = 1) in vec3 color;
uniform mat4 model;
out vec3 outColor;
void main()
{
outColor = color;
gl_Position = model * position;
}
El shader de fragmento es aún más simple. Simplemente coge el color que recibe del shader de vértice y lo devuelve.
#version 330 core
in vec3 outColor;
out vec4 FragColor;
void main()
{
FragColor = vec4(outColor, 1.0);
}
La última parte que queda de este rompecabezas ECS es la de los sistemas. Básicamente, podemos definir una función RunSystems que se ejecuta en cada iteración del bucle de juego:
void FrancisECS::RunSystems(float deltaTime)
{
//Run rest of systems...
//...
RunTransformSystem(deltaTime);
//Run sprite renderer system
RunSpriteRendererSystem();
}
RunTransformSystem será el sistema que se encargue de rotar las entidades, y es tan simple como esto para este ejemplo:
void RunTransformSystem(float deltaTime)
{
for (auto& transform : transforms)
{
transform.rotation += 100.0f * deltaTime;
}
}
Fíjate que al ser tan simple y actuar sobre conjuntos independientes de datos sería trivial que este sistema se ejecutara en múltiples threads, donde cada thread rotaría un sub-conjunto del total de entidades.
Por último, RunSpriteRendererSystem es el sistema que se encarga de dibujar cada quad y quedaría así:
void RunSpriteRendererSystem()
{
glUseProgram(defaultSpriteShaderProgram);
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(defaultSpriteShaderProgram, "model"), 1, GL_FALSE, glm::value_ptr(model));
glBindVertexArray(sprRenderer.vertexArrayObject);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
}
}
Vamos recorriendo los componentes y para cada uno, obtenemos primero a qué entidad se refiere. De esta forma, podemos recuperar el transform de dicha entidad, el cual necesitamos para trasladar, rotar y escalar el quad en pantalla, que es justo lo que hacemos a continuación. Después pasamos esta matriz de modelo como uniform al shader de vértice, y dibujamos el quad en pantalla.
Para terminar, nos quedaría ver el código cliente que llama a estas funciones.
for (int i = 0; i < 21; ++i)
{
for (int j = 0; j < 21; ++j)
{
auto entityHandle = FrancisECS::CreateGameEntity(FrancisECS::Vector3(-1.0 + (float)i * 0.1, 1.0 - (float)j * 0.1, 0.0), 0.0, FrancisECS::Vector3(0.5f, 0.5f, 1.0f));
FrancisECS::AddSpriteRendererComponent(entityHandle, FrancisECS::Vector3((float)i/11.0, 0.0, (float)j / 11.0));
}
}
Estamos creando 441 entidades, cada una en una posición diferente y a la mitad de escala original. Luego, le añadimos un componente sprite renderer con un color diferente a cada uno.
Por último, en el game loop, llamaríamos a RunSystems(deltaTime) para ejecutar los sistemas que rotan los quads y los dibujan.
Y hasta aquí la primera parte de esta entrada. En la siguiente vemos cómo implementar batching para reducir el número de draw calls, y en la última aplicaremos instancing.
Si quieres ver el código del proyecto e incluso descargarlo y probarlo tú mismo, puedes descargarlo del repositorio.
Muy interesante el artículo!!! esperamos ver mas entradas en el blog 🙂 ya que no abundan los blogs de programación gráfica en español 🙂
¡Muchas gracias! Sí, es cierto que en español no tenemos mucho material sobre estos temas, por eso decidí hacer el blog. Hay tanto por cubrir y tan poco tiempo… pero bueno, poco a poco iré escribiendo sobre cosas que me parezcan interesantes. ¡Saludos!