OpenGL - How to understand the relationship between VAO and VBO

Series Article Directory



1 Introduction

In the previous chapter LearnOpenGL Notes - Getting Started 04 Hello, Triangle introduced many concepts, VBO, VAO, EBO, Shader and so on. Intensive knowledge points bombard you, making the difficulty of this chapter rise sharply. To be honest, this chapter quite discouraged me. There are too many confusions in my heart that have not been answered. Although the article explains VBO, VAO, etc., the explanation does not make me, a beginner, understand. So much so that the reader is quite frustrated.

Today I try to turn the concept of this chapter into a "kindergarten", from the perspective of beginners, to understand concepts such as VAO and VBO in the form of pseudo-code.

2. The entrance of the rendering pipeline - vertex shader

Whether we use OpenGL to render a triangle or a complex model, we just input some vertex data and get a picture.
insert image description here
The Rendering pipeline consists of multiple stages (described in detail in the previous chapter of this part), including vertex shaders, geometry shaders, fragment shaders, and more.

2.1 Vertex shader processing

Among them, the vertex shader is located in the first stage of the entire Pipeline, and all vertex data is first sent to the vertex shader. It receives data such as vertex coordinates, colors, texture coordinates, etc., and transforms these data, such as rotation, scaling, translation, etc., and finally passes the processed vertex data to subsequent rendering steps.

Taking rendering a triangle as an example, its vertex shader code is very simple:

const char *vertexShaderSource = R"(
    #version 330
    layout (location = 0) in vec3 aPos;
    void main()
    {
        gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
    }
)";

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

vertices[]Among them, the positions of three vertices are stored in , but looking at the code of the vertex shader, it is found that it only processes one vertex. This is my first confusion: how does OpenGL render multiple vertices?

In fact, a vertex shader can run in parallel on a graphics processing unit (GPU), which means it can process multiple vertex data at the same time. In a GPU, there are a large number of simple processing units that can process vertex data simultaneously.

For example, assuming that there are 100 vertex data and 10 processing units on the GPU, the processing process of the vertex shader is about

  1. Data distribution: 100 vertex data are distributed to 10 processing units on the GPU. The amount of vertex data allocated to each processing unit may vary.
  2. Data Processing: Each processing unit independently processes the vertex data assigned to it. Transformations (such as rotation, scaling, translation, etc.) defined in the Vertex shader are applied to each vertex data.
  3. Result Merging: The results processed by each processing unit are merged together. The final result is the processing result of 100 vertex data.
  4. Pass the result: The processed vertex data is passed to the subsequent rendering steps to complete the rendering of the 3D graphics.

This is a simplified description of the process, the actual process may be more complicated. However, through the above process, 100 vertex data can be processed efficiently, enabling efficient 3D graphics rendering.

We use pseudocode to describe the above process:

#define NUM_VERTICES 100
#define NUM_UNITS 10

vector<vec3> vertex_data(NUM_VERTICES); // 有 100 个顶点数据

// 1. 数据分配
vector<vec3> processing_unit_data[NUM_UNITS]; // 有 10 个处理单元,每个单元处理 10 个顶点
const int num_vertices_per_unit = NUM_VERTICES / NUM_UNITS;
for (int i = 0; i < NUM_UNITS; i++) {
    
    
    processing_unit_data[i].assign(vertex_data.begin() + i * num_vertices_per_unit,
                                    vertex_data.begin() + (i + 1) * num_vertices_per_unit);
}

// 2. 数据处理
for (int i = 0; i < NUM_UNITS; i++) {
    
    
    for (int j = 0; j < processing_unit_data[i].size(); j++) {
    
    
        processing_unit_data[i][j] = vertex_shader(processing_unit_data[i][j]);
    }
}

// 3. 结果合并
vector<vec3> result; // 最终得到 100 个处理后的数据
for (int i = 0; i < NUM_UNITS; i++) {
    
    
    result.insert(result.end(), processing_unit_data[i].begin(), processing_unit_data[i].end());
}

// 4. 传递结果
render(result);

In the 2. data processing part of the pseudocode, a for loop is used to execute the shader on each GPU processing unit sequentially. But please note that this part is parallel in the actual GPU operation, and the GPU can process very, very much data in parallel. As shown below
insert image description here

2.2 Enter more data

When drawing the triangle earlier, we entered the triangle's vertex position data . In order to draw a more exquisite and complex model, the data we need to input is not only the vertex position, but also the color, texture coordinates, normal vector coordinates and other data. We collectively call these vertex attributes , and as the name suggests, they actually describe certain properties of vertices.

If the vertex shader is regarded as a function, if the input only has vertex position information, then it can be understood that the function parameter has only one; when the vertex shader inputs more other vertex attributes, such as the color of the input vertex, then the There are two function input parameters:

void vertex_shader(vec3 pos);	// 输入顶点位置数据
void vertex_shader(vec3 pos, vec3 color); // 输入顶点位置数据、顶点颜色数据

Multiple inputs are reflected in the shader source code, and are represented by multiple invariables , for example

const char *vertexShaderSource_one_input = R"(
    #version 330
    layout (location = 0) in vec3 aPos; // 顶点位置数据
    void main()
    {
    
    
        // ...
    }
)";

const char *vertexShaderSource_two_input = R"(
    #version 330
    layout (location = 0) in vec3 aPos; // 顶点位置数据
    layout (location = 1) in vec3 aColor; // 顶点颜色数据
    void main()
    {
    
    
        // ...
    }
)";

OpenGL ensures that at least 16 4-component vertex attributes are available. That is to say, our vertex_shaderfunction can handle at least 16 parameter inputs. At this time, when the GPU executes the shader, it will input multiple data, as shown in the figure below:
insert image description here

3. VBO Vertex Buffer Object

The input of the vertex shader is the vertex attribute data, so where is the data stored? The answer is stored in the video memory .

You might say: "No, look at the previous vertices[] variable, it is stored in the code, and the data in the code should be stored in memory."

That's right, verticesit is indeed stored in memory, but we need to use the OpenGL API to copy the data stored in memory to video memory. In video memory, we need a similar verticesobject to represent this video memory, and such an object is VBO.
insert image description here

3.1 Storage method of vertex attribute data

Assuming that when rendering a triangle, in addition to the vertex position data, there is also the color information of each vertex, so how can these two kinds of information be arranged?
Position and color are different attributes. For programming intuition, I prefer to use two arrays to store them separately, for example, 3 xyz vertex positions and 3 rgb color data:

// xyz
float positions[] = {
    
    
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

// rgb
float colors[] = {
    
    
	1.0f, 0.0f, 0.0f,
	0.0f, 1.0f, 0.0f,
	0.0f, 0.0f, 1.0f,
}

Correspondingly, you will use the OpenGL API to create two vbos, positionsand colorscopy the data of and from the memory to the video memory respectively. The code is roughly like this:

GLuint vbos[2] = {
    
    
    0,0
};
glGenBuffers(2, vbos);

// copy positions to first vbo
glBindBuffer(GL_ARRAY_BUFFER, vbos[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

// copy colors to second vbo
glBindBuffer(GL_ARRAY_BUFFER, vbos[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);

Of course, you could put all the vertex attribute data in an array and a vbo, e.g.

float vertices[] = {
    
    
        // 位置              // 颜色
        0.5f,  -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,  // 右下
        -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
        0.0f,  0.5f,  0.0f, 0.0f, 0.0f, 1.0f  // 顶部
};
GLuint vbo{
    
    0}
glGenBuffers(1, vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

What are the pros and cons of the two approaches?

Store data in a single VBO:

  • advantage:
    • Ease of use: just create a VBO to store all the data.
    • Efficient: If all data is used together, the number of data transfers between CPU/GPU can be reduced.
  • shortcoming:
    • Inflexible: If you want to modify some data, you have to update the whole VBO.
    • Long update time: Due to the large amount of data, it may take a long time to update the VBO.
    • Occupies a lot of memory: Due to the large amount of data, it may take up a lot of memory.

Store data in multiple VBOs:

  • advantage:
    • Flexible: Data in each VBO can be modified individually.
    • Short update time: Due to the small amount of data in each VBO, the update VBO time may be short.
    • Small memory footprint: Due to the small amount of data in each VBO, the memory footprint may be small.
  • shortcoming:
    • Slightly more complicated: Multiple VBOs need to be managed to ensure all data is rendered correctly.
    • Low efficiency: If all data is used together, it may increase the number of data transfers between CPU/GPU, resulting in reduced rendering efficiency.

In general, if all the data is used together, it may be more efficient to use a single VBO. But if you need the flexibility to modify the data, using multiple VBOs may be more appropriate. Therefore, the choice of using a single VBO or multiple VBOs depends on the needs of the specific application.

3.2 Get data from VBO

VBO represents a piece of video memory, which stores a lot of data. As mentioned earlier, the input of the vertex shader comes from the video memory, which is actually from the VBO.

Now think about a problem: one VBO may store a lot of data, including position, color, etc., and there may be multiple VBOs in the video memory that store these data respectively. So how does OpenGL correctly find these data and feed them to the shader when rendering?

insert image description here
The answer to this question is actually VAO, but before explaining this question, let's take a look at what information the GPU needs to know in order to get the data correctly.

Still drawing triangles, the vertex shader inputs vertex information and color information, and its source code is roughly like this:

const char *kVertexShaderSource = R"(
    #version 330
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec3 aColor;

    out vec3 ourColor;

    void main()
    {
    
    
        gl_Position = vec4(aPos, 1.0);
        ourColor = aColor;
    }
)";

Assuming that the vertex data includes position and color, all of which are placed in a VBO, then you may put it in this way, first store all xyz, then all rgb, and give it a name that is easy to remember, called planar type :

x0 y0 z0 x1 y1 z1 x2 y2 z2 r0 g0 b0 r1 g1 b1 r2 g2 b2

It is also possible to store the xyz and rgb of the first point, and then the second point, and so on. This kind of name is also called interleaving :

x0 y0 z0 r0 g0 b0 x1 y1 z1 r1 g1 b1 x2 y2 z2  r2 g2 b2

Both of these are reasonable places to store data, and you want to provide an interface that is flexible enough to support both layouts.

If you are a GPU, then the pseudo code to get vertex attributes from VBO is roughly like this:

void* vbo = some_address;
const int num_vertex = 3;
const int vertex_pos_index = 0;
const int vertex_rgb_index = 1;

for(int i = 0; i < num_vertex; ++i)
{
    
    
	vec3_float xyz = getDataFromVBO(vbo, i, ...);
	vec3_float rgb = getDataFromVBO(vbo, i, ...);
	auto result = vertex_shader(xyz, rgb);
}
// ...

in:

  • vboPoint to the address of a video memory, think of it as a familiar C pointer
  • vertex_pos_index = 0and vertex_rgb_index = 1, corresponding to layout (location = 0) in vec3 aPosand layout (location = 1) in vec3 aColor. Indicates which vertex attribute you want
  • getDataFromVBOBy getting ithe position information and color information of the vertices from the vbo
  • vertex_shaderInput two parameters, namely vertex position information and color information

Now, you have to think about how to implement getDataFromVBOthe function . For the sake of simplicity, assume that vbo stores all float type data and returns vec3_float data (as a std::vector with a size of 3). In order to be compatible with the two data layouts mentioned above, we introduce stride and offset parameters, and getDataFromVBOthe implementation is roughly like this:

vec3_float getDataFromVBO(VBO vbo, int vertex_index, int stride, int offset)
{
    
    
	const int num_float_in_vec3 = 3;
	float* begin = (float*)(vbo) + offset;	// 起始位置偏移
	const int vertex_offset = vertex_index * stride; // 第 i 个顶点属性的获取位置
	vec3_float result = vec3_float{
    
    begin + vertex_offset, begin + vertex_offset + num_float_in_vec3}
	return result;
}

offsetThe parameter is well understood, namely the offset. The following table lists the different types of vertex position information (xyz) and color information (rgb) requiredoffset

location data color data
flat type 0 9
Interwoven 0 3
  • For flat type, the offset of the first vertex position (x0) is 0; the offset of the first vertex color (r0) is 9
  • In interlaced mode, the offset of the first vertex position (x0) is 0; the offset of the first vertex color (r0) is 3

strideThe parameter means "step size", which refers to how many units I need to cross domains in order to get the next data. The following table lists the different types of vertex position information (xyz) and color information (rgb) requiredstride

location data color data
flat type 3 3
Interwoven 6 6
  • In the plane type, the current vertex position to the next vertex position needs to span 3 units, such as x0 to x1, with 3 data in between; stridethe same is true
  • In interlaced mode, the current vertex position to the next vertex position needs to span 6 units, for example, x0 to x1, with 6 data in between; stridethe same is true

Great, with the strideand offsetparameters we can handle two different permutations nicely. Now, according to whether we want to get the vertex position or the color, we can get the data from the vbo smoothly by setting different parameters. The pseudocode is updated to:

void* vbo = some_address;
const int num_vertex = 3;
const int vertex_pos_index = 0;
const int vertex_index_0_offset = 0; // 平面型为 0,交织型为 0
const int vertex_index_0_stride = 3; // 平面型为 3,交织型为 6

const int vertex_rgb_index = 1;
const int vertex_index_1_offset = 9	 // 平面型为 9,交织型为 3
const int vertex_index_1_stride = 3; // 平面型为 3,交织型为 6

for(int i = 0; i < num_vertex; ++i)
{
    
    
	vec3_float xyz = getDataFromVBO(vbo, i, 
			vertex_index_0_stride,
			vertex_index_0_offset);
	vec3_float rgb = getDataFromVBO(vbo, i, 
			vertex_index_1_stride,
			vertex_index_1_offset);
	auto result = vertex_shader(xyz, rgb);
}

3.3 Going further

Maybe you feel that what I explained earlier is actually the parameter part of glVertexAttribPointerthe function . Let's move on to refinement and get the pseudocode closer glVertexAttribPointer.

First of all, in the previous pseudo code, we get a vec3 by default. In actual usage scenarios, not all vertex attributes are necessarily vec3, maybe vec4 or vec2, or even a single float. Therefore, we abstract the number of attributes into sizethis parameter, and get:

vecn_float getDataFromVBO(VBO vbo, int vertex_index, int size, int stride, int offset)
{
    
    
	float* begin = (float*)(vbo) + offset;	// 起始位置偏移
	const int vertex_offset = vertex_index * stride; // 第 i 个顶点属性的获取位置
	vecn_float result = vec3_float{
    
    begin + vertex_offset, begin + vertex_offset + size}
	return result;
}

Then, the vertex attribute is not necessarily of floattype , it may be intof booltype. Abstract the type as a new parameter,type

enum DataType
{
    
    
	GL_BYTE, 
	GL_SHORT, 
	GL_INT,
	GL_FLOAT,
}
vecn getDataFromVBO(VBO vbo, int vertex_index, int size, DataType type, int stride, int offset)
{
    
    
	type* begin = (type*)(vbo) + offset;	// 起始位置偏移
	const int vertex_offset = vertex_index * stride; // 第 i 个顶点属性的获取位置
	vecn result = vecn{
    
    begin + vertex_offset, begin + vertex_offset + size}
	return result;
}

Finally, to be more general, we use strideboth and offsetin bytes:

vecn getDataFromVBO(VBO vbo, int vertex_index, int size, DataType type, int stride, int offset)
{
    
    
	void* begin = vbo + offset;	// 起始位置偏移
	const int vertex_offset = vertex_index * stride; // 第 i 个顶点属性的获取位置
	const int vertex_size = sizeof(tpye) * size;
	vecn result = vecn{
    
    begin + vertex_offset, begin + vertex_offset + vertex_size}
	return result;
}

After the above adjustments, the pseudo code for obtaining vertex data from vbo is updated as follows:

void* vbo = some_address;
const int num_vertex = 3;
const int vertex_pos_index = 0;
const int vertex_index_0_size = 3;
const int vertex_index_0_type = GL_FLOAT;
const int vertex_index_0_offset = 0; 
const int vertex_index_0_stride = 3 * sizeof(float);

const int vertex_rgb_index = 1;
const int vertex_index_1_size = 3;
const int vertex_index_1_type = GL_FLOAT;
const int vertex_index_1_offset = 9 * sizeof(float)
const int vertex_index_1_stride = 3 * sizeof(float);

for(int i = 0; i < num_vertex; ++i)
{
    
    
	vec3_float xyz = getDataFromVBO(vbo, i, 
				vertex_index_0_size,
				vertex_index_0_type,
				vertex_index_0_stride,
				vertex_index_0_offset
				);
	vec3_float rgb = getDataFromVBO(vbo, i, 
				vertex_index_1_size,
				vertex_index_1_type,
				vertex_index_1_stride,
				vertex_index_1_offset 
				);
	auto result = vertex_shader(xyz, rgb);
}

4. Relationship between VAO and VBO

In the previous three chapters, we sorted out the process of obtaining vertex attribute data from vbo and then sending it to the shader for rendering. We found that if we want to successfully get the data from the video memory, we need to give a series of parameters, including, etc., sizeand stridealso To specify which vbo to take from.

Sometimes, we have a lot of models to render. If we set the parameters before using the model, the process will be very cumbersome. People wonder if it is possible to use an object to store these things, so VAO (Vertex Array Object) appeared.

In OpenGL we use glVertexAttribPointerto set the vertex attribute array attribute and position, it stores the data format and position of the vertex attribute array in the currently bound VAO for use in rendering.

If you use pseudocode to describe what glVertexAttribPointeryou did , it might look like this:

// 定义一个glVertexAttribPointer函数
function glVertexAttribPointer(index, size, type, normalized, stride, offset) {
    
    
  // 获取当前绑定的VAO和VBO
  vao = glGetVertexArray();
  vbo = glGetBuffer();

  // 检查参数的有效性
  if (index < 0 or index >= MAX_VERTEX_ATTRIBS) {
    
    
    return GL_INVALID_VALUE;
  }
  if (size < 1 or size > 4) {
    
    
    return GL_INVALID_VALUE;
  }
  if (type not in [GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT, GL_UNSIGNED_SHORT, GL_INT, GL_UNSIGNED_INT, GL_FLOAT]) {
    
    
    return GL_INVALID_ENUM;
  }
  if (stride < 0) {
    
    
    return GL_INVALID_VALUE;
  }
  
  // 将顶点属性数组的数据格式和位置存储在VAO中
  vao.vertexAttribs[index].enable = true;
  vao.vertexAttribs[index].size = size;
  vao.vertexAttribs[index].type = type;
  vao.vertexAttribs[index].normalized = normalized;
  vao.vertexAttribs[index].stride = stride;
  vao.vertexAttribs[index].offset = offset;
  vao.vertexAttribs[index].buffer = vbo;
}
  • First, get the currently bound vao and vbo from the OpenGL Context
  • There is an vertexAttribsarray , indexset the current property to this array

Yes, the relationship between vao and vbo is that simple: vao records the parameters of how to get data from vbo.

5. Understand the code

Let's go back to the code level and look at the code snippet that made me confused at the beginning, the use of vao and vbo:

	GLuint VBO{
    
    0};
    GLuint VAO{
    
    0};
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);

    glBindVertexArray(VAO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

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

    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)(9 * sizeof(float)));
    glEnableVertexAttribArray(1);

I know every function in this code, but the relationship between functions is unclear. glVertexAttribPointerFor example, it applies to the previously bound vao and vbo, but there is no reflection in the function parameters, which leads to a "fault" in the understanding of this code. The main reason is that the modification and access to OpengGL Context properties are hidden behind the OpenGL API. How this part is implemented is unknown to us.

Now, in order to better understand this code, try to use pseudocode to explain what each function does.

class OpenGLContext
{
    
    
public:
	const int max_num_vao = 256;
	const int max_num_buffers = 256;
	std::vector<Buffer> buffers(256);
	std::vector<VAO> vaos(256);

	VAO* current_vao;
	VBO* current_vbo;
}

// 全局的 OpenGL Context 对象
OpenGLContext context;
void glGenBuffers(GLsizei n, GLuint * buffers)
{
    
    
	static int count = 0;
	GLuint* index = new GLuint[n];
	for(int i = 0; i < n; ++i){
    
    
		index[i] = ++count;
	}
	
	for(int i = 0; i < n; ++i){
    
    
		// create_new_vao 创建一个新的 vao 对象
		context.buffers[index[i]] = create_new_buffer_ojbect();
	}
	buffers = index;
}

void glGenVertexArrays(	GLsizei n, GLuint * arrays)
{
    
    
	static int count = 0;
	
	GLuint* index = new GLuint[n];
	for(int i = 0; i < n; ++i){
    
    
		index[i] = ++count;
	}
	
	for(int i = 0; i < n; ++i){
    
    
		// create_new_vao 创建一个新的 vao 对象
		context.vaos[index[i]] = create_new_vao();
	}
	arrays = index;
} 

void glBindBuffer(GLenum target,GLuint buffer)
{
    
    
	if(target == GL_ARRAY_BUFFER){
    
    
		context.current_vbo = &context.buffers[buffer];
	}
	//....
}
void glBufferData(GLenum target,GLsizeiptr size, const void * data, GLenum usage)
{
    
    
	if(target == GL_ARRAY_BUFFER){
    
    
		copy_data_to_vbo(size, data, context.current_vbo);
	}
}

void glVertexAttribPointer(GLuint index,
 	GLint size,
 	GLenum type,
 	GLboolean normalized,
 	GLsizei stride,
 	const void * pointer)
{
    
    
  VBO* vbo = context.current_vbo;
  VAO* vao = context.current_vao;
  
	// 将顶点属性数组的数据格式和位置存储在VAO中
  vao.vertexAttribs[index].enable = true;
  vao.vertexAttribs[index].size = size;
  vao.vertexAttribs[index].type = type;
  vao.vertexAttribs[index].normalized = normalized;
  vao.vertexAttribs[index].stride = stride;
  vao.vertexAttribs[index].offset = offset;
  vao.vertexAttribs[index].buffer = vbo;
}

Through the above pseudo code, you should have a general understanding of what the OpenGL API does and what is the connection between them. I'm tired of writing here, so I won't write more explanations and explanations. You should be able to understand it if you are smart.

6. Summary

This article tries to explain the relationship between VAO and VBO to newcomers who are just getting started with OpenGL. Starting from the vertex shader, it explains how vertices are sent to the GPU in the rendering process; then introduces the concept of vbo, which is actually a pointer to video memory; To copy data from the memory to the video memory, we need to specify a lot of parameters. If we have to re-specify the parameters every time we render a model, the whole process will become very cumbersome. Since the vao object is introduced to store these parameters, only the parameters need to be set You can reuse it all once; finally, use pseudocode to explain those initially confusing OpenGL functions.

Guess you like

Origin blog.csdn.net/weiwei9363/article/details/128989702