DRAW_INDEX与图形流水线

前言

  • 介绍GPU的图形流水线文章有很多,因为是原理类的东西,所以比较抽象。本文试图从渲染命令DRAW_INDEX的角度,来观察GPU图形流水线工作过程,中间会结合应用程序,Mesa驱动和内核驱动等相关代码来分析DRAW_INDEX命令的使用方法,进一步分析流水线工作原理。

DRAW_INDEX

命令格式

  • DRAW_INDEX是AMD GPU最基本的图元绘制命令,类型属于type-3,它的功能是从内存中取出顶点索引信息,然后找到对应的顶点数据,根据顶点数据画出对应的图像并显示到屏幕。AMD GPU的手册描述如下:
    在这里插入图片描述
  • 想知道GPU要绘制一个图元,需要一些什么信息,可以根据DRAW_INDEX命令的具体格式来分析,如下:
    在这里插入图片描述
  1. HEADER:GPU命令的通用字段,所有类型的包都使用这个字段,包含包类型和包长度信息
  2. INDEX_BASE:这个字段记录着存放索引的内存地址
  3. INDEX_COUNT:索引个数
  4. DRAW_INITIATOR:绘制初始化值
  • 从上面的分析看出,DRAW_INDEX命令中包含的是index buffer的地址和index的个数,以及初始化值,主要的信息就是索引缓冲的地址,这是什么?索引缓冲实际就是一个数组,存放的元素用来索引顶点数据。
  • 我们看一下mesa中怎么使用这个命令:
r600_draw_vbo
	uint64_t va = r600_resource(indexbuf)->gpu_address + index_offset;		/* 5 */
	radeon_emit(cs, PKT3(PKT3_DRAW_INDEX, 3, render_cond_bit));				/* 6 */
	radeon_emit(cs, va);													/* 7 */
	radeon_emit(cs, (va >> 32UL) & 0xFF);									/* 8 */
	radeon_emit(cs, info->count);											/* 9 */
	radeon_emit(cs, V_0287F0_DI_SRC_SEL_DMA);				/* 10 */
5. 首先计算索引缓冲的地址,用于填充渲染命令的BODY
6. 填充渲染命令的头部,该命令属于type-3类型,BODY长度为4个双字,4*4 = 16bytes
7. 填写索引缓冲的低4字节(32bit)
8. 填写索引缓冲的高4字节(32bit)
9. 填写索引缓冲中元素的个数,实际上就是顶点的个数,因为每个索引对应地可以找到一个顶点
10. 填写标志,用于指示GPU在绘制图元时怎么获取顶点信息,这里时通过DAM从主存获取 
  • 从AMD的手册中我们可以看到,要让GPU要绘制一个图元非常简单,只需要准备好相应的顶点信息,然后将其索引放到索引缓存中,将其封装成渲染命令,提交到ring buffer中,GPU就可以处理了。上面的介绍中有一个顶点索引的概念,这是什么?图元的顶点直接给出来不就好了吗?为什么要顶点的索引?下一小节解释顶点索引

顶点索引

  • 我们知道现代GPU都使用逐片元的方式进行渲染,即将三维模型分成很多个小的片元,每个片元通常都是三角形,三维模型的绘制因此被分解成对每个三角形的绘制。三角形由三个点组成,这里我们可以把它理解成顶点,顶点最基本的属性就是三维坐标,除此之外还包括颜色,纹理等。GPU输入就是若干顶点的集合,从上面的图形流水线我们也可以看到,流水线开始处硬件单元就是VGT(Vetext Grouper/Tesselator),这个硬件单元的功能就是解析并存放顶点数据,并且还可以自动生成顶点信息,从而丰富细节。

VBO Example

  • CPU为了让GPU绘制图元,不得不将顶点信息传递给GPU,当顶点数据越多时,占用的内存带宽越大,因此我们希望在能够绘制相同图元的情况下,传输的顶点数据越少越好,这样可以减少CPU和GPU之间带宽占用。举个例子,我们想绘制一个四边形,由于OpenGL主要处理三角形,我们可以绘制两个三角形来组成一个四边形,假设以下是绘制四边形的顶点集合:
float vertices[] = {
	/* 第一个三角形 */
    -0.5f, 0.0f, 0.0f, // 左顶点
     0.5f, 0.0f, 0.0f, // 右顶点
     0.0f,  0.5f, 0.0f  // 上顶点
};
float vertices1[] = {
	/* 第二个三角形 */
    -0.5f, 0.0f, 0.0f, // 左顶点
     0.5f, 0.0f, 0.0f, // 右顶点
     0.0f,  -0.25f, 0.0f  // 下顶点
};
  • 通过以下代码(完整代码看这里),我们可以绘制一个四边形:
......
unsigned int VBO, VBO1, VAO, VAO1;												/* 1 */

glGenVertexArrays(1, &VAO);														/* 2 */
glGenBuffers(1, &VBO);								
glBindVertexArray(VAO);															/* 3 */
glBindBuffer(GL_ARRAY_BUFFER, VBO);					
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);		/* 4 */

glGenVertexArrays(1, &VAO1);
glGenBuffers(1, &VBO1);
glBindVertexArray(VAO1);
glBindBuffer(GL_ARRAY_BUFFER, VBO1);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices1), vertices1, GL_STATIC_DRAW);

while (!glfwWindowShouldClose(window))
{
    ......
    glBindVertexArray(VAO);														/* 5 */
    glDrawArrays(GL_TRIANGLES, 0, 3);											/* 6 */

    glBindVertexArray(VAO1);													/* 7 */	
    glDrawArrays(GL_TRIANGLES, 0, 3);
	......
}
1. 声明顶点缓冲对象和顶点数组对象
2. 分配顶点数组对象和顶点缓冲对象,mesa分别维护了顶点数组对象和缓冲对象的hash表(ctx->Shared->BufferObjects),分配实际上就是从
hash表中查找可用的key
3. 绑定数组对象和缓冲对象,mesa维护了OpenGL渲染的上下文gl_context(mtypes.h ),这是一个全局的变量,绑定的主要动作就是让上下文
中的Array数组指向新配备的数组对象,缓冲对象同理
4. 将用户定义的顶点数据拷贝到缓冲对象中。如果是amd r600 gpu,最终调用r600_buffer_subdata,如果是virtio gpu,最终调用
virgl_buffer_subdata,无论是哪种GPU的驱动,核心动作都是将顶点数据拷贝到VBO关联的内存buffer中,而该buffer通过顶点数组VAO就可以找到
5. 将当前上下文与第一个顶点数组VAO关联,通过VAO可以找到顶点数据vertices
6. 下发绘制命令给GPU,实质就是将顶点数据准备好,然后组装DRAW_INDEX渲染命令,将其提交给GPU执行,GPU执行命令时将CPU准备的顶点数
据拷贝到GPU硬件单元,然后处理顶点,绘制图元
7. 将当前上下文与第一个顶点数组VAO1关联,通过VAO1可以找到顶点数据vertices1,同5

IBO Exmaple

  • 从上面的例子可以看出,我们绘制4边形用了两个三角形,其中有两个顶点重复了,当GPU绘制图元时不可避免的会传递这两个顶点数据,顶点索引就可以避免这种情况,节约CPU和GPU之间的传输带宽,当顶点数据很多时,这种优化是可观的,下面的代码使用IBO(Index Buffer Object)完成同样的四边形绘制,但少传输了两个顶点数据。
float vertices[] = {
	/* 绘制四边形需要的所有顶点数据 */
    -0.5f, 0.0f, 0.0f,   // 左顶点
     0.5f, 0.0f, 0.0f,   // 右顶点
     0.0f, 0.5f, 0.0f,   // 上顶点
     0.0f, -0.25f, 0.0f  // 下顶点
};

unsigned int indices[] = {
	/* 顶点索引 */
    0, 1, 2,			/* 第一个三角形 */
    0, 1, 3				/* 第二个三角形 */
};
  • 当我们要绘制三角形时,首先将所有可能用到的顶点数据传给GPU,在真正发起DRAW_INDEX绘图命令时,我们只传入这一次绘图命令需要的顶点在顶点数组中的索引,这样就可以避免顶点的重复传输,比如画第一个三角形时,我们传入顶点索引0,1,2用来指示GPU绘图,画第二个三角形时,我们传入顶点索引0,1,3。代码如下(完整代码看这里):
......
unsigned int VBO, VAO, EBO;								/* 1 */
glGenVertexArrays(1, &VAO);								/* 2 */
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);

glBindVertexArray(VAO);									/* 3 */
glBindBuffer(GL_ARRAY_BUFFER, VBO);						
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);			/* 4 */

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);				/* 5 */
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);	/* 6 */
while (!glfwWindowShouldClose(window))
{
	......
    glBindVertexArray(VAO);								/* 7 */
    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);/* 8 */
	......
}
1. 声明顶点缓冲对象,顶点数组和索引缓冲对象,我们需要用索引缓冲对象来向GPU指明绘制图元时需要用的顶点
2. 分配顶点各个对象,对于每类对象,mesa中都维护有对应的hash表,分配对象就是查找可用的key
3. 绑定顶点数组对象和顶点缓冲对象,后续所有的VBO和EBO缓存都会被顶点数组对象记录
4. 将顶点数据传递给缓冲对象
5. 绑定索引缓冲对象
6. 将索引数据传递给缓冲对象
7. 再次绑定顶点数组到当前上下文,让mesa可以通过VAO找到对应的VBO和EBO
8. 索引绘制三角形,glDrawElements会将当前上下文绑定的EBO作为顶点索引,VBO作为顶点数据,传递给GPU,然后执行DRAW_INDEX命令

使用举例

glBufferData																					/* 1 */
	_mesa_BufferData
		_mesa_buffer_data
			buffer_data_error
				buffer_data
					ctx->Driver.BufferData							<->	st_bufferobj_data
						bufferobj_data
							screen->resource_create					<->	r600_resource_create	/* 2 */
							pipe_buffer_write
								pipe->buffer_subdata				<->	r600_buffer_subdata		/* 3 */
									r600_buffer_transfer_map									/* 4 */
										memcpy(map, data, size)									/* 5 */
									r600_buffer_transfer_unmap
glDrawArrays																					/* 6 */
	_mesa_DrawArrays
		_mesa_draw_arrays
			ctx->Driver.Draw										<->	st_draw_vbo
				cso_draw_vbo
					u_vbuf_draw_vbo
						pipe->draw_vbo								<->	r600_draw_vbo			/* 7 */
							radeon_emit(cs, PKT3(PKT3_DRAW_INDEX, 3, render_cond_bit))			/* 8 */
							radeon_emit(cs, va)
							radeon_emit(cs, (va >> 32UL) & 0xFF)
							radeon_emit(cs, info->count)
							radeon_emit(cs, V_0287F0_DI_SRC_SEL_DMA)
1. 用户态调用OpenGL接口,将顶点数据拷贝到当前上下文绑定的VBO中
2. 不同GPU,资源创建和顶点数据拷贝实现不一样,统一被抽象成pipe_context,pipe_screen对象,各自在pipe_context,pipe_screen
中实现自身的接口,这里以amd r600系列GPU为例,它的资源创建被封装成r600_resource_create函数,通常这个函数由驱动厂商实现,其实质
就是在GTT中分配一块内存给CPU,当CPU要向GPU传输数据时就将数据放到资源关联的内存中,GPU就可以访问。这里要注意,GTT的内存管理在
内核态,用户态在GTT申请资源后没法直接访问它的buffer,需要经过映射才能访问
3. 往资源关联的内存中写入顶点数据
4. 将GPU资源映射到用户态,本质就是将内核的内存地址映射到应用程序的地址空间
5. 拷贝顶点数据,这一步是整个glBufferData的核心动作
6. 用户态调用OpenGL接口,发起图元绘制命令
7. 调用amd r600系列GPU的绘制接口
8. 向用户态的command stream中填写PKT3_DRAW_INDEX命令,传递到内核态,然后封装命令内容,将其上传到Ring Buffer中,最后通知GPU执行
  • 上面介绍的是应用程序如何间接调用DRAW_INDEX命令,最终渲染命令的提交,还是会交给内核态,这中间渲染命令如何下发到内核态,内核态怎么提交任务给GPU,可以参考之前的系列文章

图形渲染简介

几个问题

  • 图形渲染流程,不仅仅包括GPU的绘制,还包括通常意义上利用计算绘制一幅图片,或者制作一个动画的过程。整个流程从上层应用程序的模型建立开始,到最终绘制图形到屏幕结束。
  • 图形渲染流程分为三个阶段:应用程序阶段,几何阶段和光栅阶段,简单介绍每个阶段的作用和输入输出。
  1. 应用程序阶段:应用程序阶段的输出是模型顶点三维坐标,法向量,纹理,纹理二维坐标等
  2. 几何阶段:基于应用程序阶段的输出数据,主要完成顶点坐标的转换,投影,裁剪
  3. 光栅化阶段:基于几何阶段的输出数据,为屏幕的像素正确配色,以便绘制完整图像,这个阶段是针对屏幕上每个像素的操作

为什么传入GPU的是三维坐标?

  • 通常的图形应用软件,提供的画图功能都是3D的,其算法实现和数学模型也是以三维坐标为基础的,这样也符合用户的习惯。应用软件调用OpenGL接口实现具体的图形渲染,也是用三维坐标作为参数传入。当然,图形应用软件也可以使用CPU完成三维坐标到二维坐标的转换,然后传给GPU,但我们知道坐标转换主要是矩阵运算,而对于矩阵运算,GPU比CPU更加擅长,因此传入三维坐标更能利用GPU的计算能力。

为什么要进行坐标转换?

  • 三维模型最终要显示到二维屏幕上,始终需要转换成二维的坐标,但这个中间还会有一系列的坐标转换,比如同样的一个三维模型,从不同的方位看,显示到屏幕上的内容肯定不同,因此需要引入一个坐标,在这个坐标空间内查到的模型顶点,都是从一个固定视角出发观察到的,这就是观察坐标;再比如现在要将两个三维模型放在一起显示到屏幕中,每个三维模型的坐标都是相对于自身的坐标系,和其它物理没有任何的参考关系,因此肯定要引入一个更大的坐标系,来将各自的三维模型联系起来,让每个三维模型都能在这个坐标空间中找到,这就是世界坐标空间
  • 进行坐标转换的主要目的,就是计算图形的顶点最终显示到屏幕上时,应该在哪个位置,如果不进行坐标转换,那么屏幕中怎样去显示一个有三维坐标的顶点呢,所以坐标转换时必须的

为什么通过矩阵运算可以完成坐标转换?

  • 一个顶点可以用三维坐标表示其位置,一条线段由两个顶点连接,可以用一个三维向量表示,而一个三维向量可以认为是一个3*1的矩阵,对这个线段的平移,缩放,旋转,都可以转化成矩阵运算。
  1. 平移,假设现在有一个线段,用向量表示为(x, y, z),如果我们要将这个线段水平方向右移一个常量,那么可以用这个矩阵与一个常数相加得到最终向量表示。如果我们要对线段进行位移操作,假设位移向量表示为:
    (x1,y1,z1)
    也可以构造一个矩阵,让向量与矩阵相乘,得到最终的向量表示,如下:
    在这里插入图片描述
  2. 缩放和旋转,同平移一样,我们都可以通过构造矩阵,然后让线段和矩阵相乘,来计算得到缩放和旋转后的向量
  • 总之所有的坐标转换,我们都可以通过构造矩阵,然后利用矩阵的加法,乘法,转秩等运算,实现我们想要的坐标转换

投影和裁剪的目的是什么?

  • 投影,就是把三维的模型投影到二维的屏幕上,就像人照镜子一样,人是立体的,但投影到镜子上的人像就是平面的了,我们可以把屏幕想象成是镜子,我们照镜子投影是自然现象,计算机中的模型投影到屏幕就是模拟这个自然现象,让三维模型投射到屏幕就像人照镜子一样
  • 裁剪,就是把三维模型投影到屏幕以外的东西删除,不显示到屏幕,可以想象我们在照一个化妆镜,那么镜子肯定不能把人的全身都显示出来,其余镜子以外的东西,就是被裁剪了

流水线

  • 图形渲染流程的三个阶段中,图形应用软件实现一些图形算法和数学建模,这个由软件完成,到几何阶段时会利用GPU实现计算功能,因此GPU从几何阶段开始介入图形渲染流程。它的输入是一系列的顶点数据,顶点数据包括三维坐标,顶点颜色,顶点纹理等。这些数据以数组的形式存放在顶点缓存中(VBO - Vetex Buffer Object),通常情况下这是一段GPU可以访问的系统内存。
  • 对于AMD R6xx GPU而言,它内部流水线的框架如下图所示:
    在这里插入图片描述

输入输出

  • 流水线的输入有几个地方,首先是Ring Buffer上的命令,这是触发GPU流水线工作的起点;然后是内存中的顶点数据,纹理数据,着色器程序,这些是GPU流水线在工作的各个阶段可能会获取的数据,如果顶点着色器要运行顶点程序,那么需要从内存中加载这个顶点程序到GPU硬件,然后执行,在比如顶点着色器处理顶点时如果需要纹理数据,也要从内存中加载
  • 流水线的输出就是Frame Buffer,可以认为它是屏幕像素的映射,显示器有一个专门用于控制屏幕上各个像素颜色的硬件单元,它可能使阴极射线管(CRT),也可能是液晶材料使用的光学器件。这些控制屏幕像素的硬件单元会从Frame Buffer中读取数据,将其与屏幕的像素对应起来,然后控制屏幕上每个像素点的输出。

顶点处理

VGT
  • 当应用程序要使用GPU绘制命令DRAW_INDEX的时侯,首先准备好要处理的图元的顶点数据,然后将索引顶点数据的索引缓存(Index Buffer Object)传递给GPU硬件单元(VGT)。VGT接收CPU传来的顶点索引数据,以此索引顶点,为了丰富细节VGT还可以主动生成顶点。VGT会将顶点数据送到SPI(shader pipe interpolator),将要绘制的图元连接信息送到PA(primitive assembly)。
SPI
  • SPI主要负责为着色处理器准备运行环境,然后运行着色处理器,准备环境通常就是把顶点数据加载到着色器内部通用的寄存器GPRs(General Purpose Registers)中,方便着色器在运行时访问。着色器执行完着色程序后,数据输出到SX(shader export)硬件缓存中。
处理器
  • 顶点着色器的硬件单元是顶点处理器,它有非常多的核,每一个核处理的指令相同,但输入的数据不同,即单指令多数据。通常一个着色器核处理一个图元中互相独立的顶点属性数据,什么意思?假设现在要绘制一个三角形,它的顶点数据和顶点解析如下:
    float vertices[] = {																			/* 1 */
        // positions         // colors
         0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,  // bottom right
        -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,  // bottom left
         0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f   // top 
    };
    
    // position attribute																			/* 2 */
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
    // color attribute																				/* 3 */
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);
1. 定义三角行三个顶点的属性数据,每个顶点有两个属性,分别是位置和颜色
2. 告诉OpenGL如何解析传入的顶点数据,这里的大意是将着色器的第0个输入变量指向数组内的0偏移处,然后以步长为6符点数逐次遍历整个顶点数组,每3个为一
个属性,作为着色器的输入。这里的实际动作就是将顶点数组中的位置数据解析,最终传入每个GPU的着色处理器中。
3. 同上,告诉OpenGL将着色器的第1输入变量指向数组内的3浮点数偏移处,然后以步长为6浮点数遍历整个顶点数组,同样每3个为一个属性数据,作为着色器的输
入
  • 顶点着色器程序如下:
layout (location = 0) in vec3 aPos;			/* 4 */
layout (location = 1) in vec3 aColor;

out vec3 outColor;							/* 5 */

void main()
{
    gl_Position = vec4(aPos, 1.0);			/* 6 */
    outColor = aColor;						/* 7 */
}
4. 声明两个变量aPos和aColor作为着色器的输入变量,每个变量通过location指定自己在多个输入变量中的位置,这里位置变量作为着色器程序的第0个输入变量,
颜色变量作为了第1个
5. 声明一个着色器的输入变量outColor
6. 将着色器的位置输入变量直接赋值给一个全局的变量gl_Position,这个变量在着色器中被默认看做是顶点的坐标信息,着色器之后就会用这个数据作为顶点的坐标
7. 将颜色输入变量赋值给outColor
  • 顶点数据的输入如下图所示,对于一个GPU核内,处理的属性都是相互独立的,这里位置和颜色独立,对于一个着色器的所有GPU核,它们处理的属性类型又是一样的,只是输入的数据不一样,这样才能实现单指令多输入的并行计算
    在这里插入图片描述
  • 以上相关代码,其完整的程序在这里

像素处理

  • TODO
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

享乐主

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值