OpenGL学习(二)渲染流水线与三角形绘制

前言

上一篇回顾:OpenGL学习(一) freeglut / GLEW 环境搭建与窗口创建

在上一篇博客中我们实现了环境的配置,但是我们只创建了白色的窗口,并未绘制任何图形,于是今天我们来绘制最基本的图形 ---- 三角形。

在开始之前,我们必须首先了解OpenGL的绘制流程,渲染流水线以及一些相关的概念。否则代码将会变得难以理解。

渲染流水线

在这一部分我们将逐渐了解如何通过基本的几何信息,一步一步绘制出我们想要的图形,这个流程称之为渲染流水线。

在这里插入图片描述

顶点与图元

OpenGL是基于GPU的图形绘制,如果你使用过其他语言的画图API,比如 python 的 matplotlib,你就能够显然发现两者的区别。

在 matplotlib 中,绘制一幅图形,我们往往需要传输一整个巨大的矩阵给我们的绘图 API,然后 matplotlib 按照图片的格式解析这个矩阵并且输出。

如下图:一种典型的cpu绘图方式

在这里插入图片描述

那么OpenGL的绘图方式有什么差异呢?回想起小学数学课上老师说的 “三角形有三个顶点” ,下图演示了使用顶点信息来绘制三角形的过程:

在这里插入图片描述

是的!只要知道三个顶点的信息,我们就能够绘制三角形了!这是一件令人兴奋的事情,这意味着我们的绘制将不在基于一板一眼的像素矩阵。我们通过基本图元来进行复杂图形的绘制!

这时候问题就来了,任何几何形状都可以作为图元!于是我们指定了一些最基本的几何形状作为OpenGL的基本图元(否则就乱套了)。下图展示了OpenGL的一些基本图元,其中三角形使用最广泛。

在这里插入图片描述

现在思路需要转变了。在OpenGL中绘制图形,我们第一件事情就是指定顶点与图元。当我们指定了顶点与图元之后,我们就能够绘制一些复杂的图形。通常都是由多个三角形拼凑出复杂的图形:

在这里插入图片描述
图片引自:百度图片

光栅化

光栅?啥玩意?请忽略这个看起来应该出现在大学物理课本中的词汇。实际上,光栅化就是由几何图元生成像素的过程

在上面的 “顶点与图元” 部分,我们了解到任意图形的绘制都是基于基本图元进行的。基本图元有一个显著的特征,那就是每个顶点之间,都是直线!

这意味着只要知晓两个顶点的坐标,我们可以求得其连线上任意点的坐标。我们通过简单的线性插值就可以确定其连线上所有点的坐标。比如我们以两个顶点生成一条直线,下面展示了光栅化的过程:

注:每个格子代表一个像素点

在这里插入图片描述

对于两个点可以线性插值,同样,三个点的情况也是可以通过线性插值去绘制的:

在这里插入图片描述

注:不光是可以通过插值生成对应位置的像素,任何顶点属性都可以插值,比如顶点颜色,顶点法向量等等

着色器

从图元装配到光栅化,通过顶点信息生成像素,我们已经能够有足够的条件去生成各种各样的图形了。但是我们不能够对我们的顶点或者像素做更多的处理。

什么?你说在传送顶点数据之前就利用cpu对其进行处理?你知道,现役的模型,有多少个顶点吗? cpu算力不够,我们往往需要利用GPU对顶点和像素进行处理,而GPU是多核的,能够很好的承担这些繁重的任务。所以着色器应运而生!

一句话:着色器是给GPU设计的小程序 (雾,如果你阅读过mc中着色器的源码,就知道这玩意代码量绝对不小。。。)

在这里插入图片描述

一般来说,着色器有四种,但是我们必须关注其中的两种:顶点着色器和片元着色器。一个粗糙的渲染流水线必须经过这两个着色器的处理:

  1. 顶点着色器阶段:每个单独的顶点作为输入,对其进行一些处理,比如坐标转换什么的
  2. 图元装配阶段: 将顶点着色器的所有输出,装配成相应的图元,比如三角形
  3. 被我们省略的着色器
  4. 光栅化阶段: 将基本图元通过线性插值法生成像素,此外,裁剪掉屏幕外的像素以提升性能
  5. 片元着色器阶段: 对光栅化阶段输出的每个像素进行处理,常用来实现一些特效

如图展示了粗糙的渲染流水线(包含顶点着色器和片元着色器):

在这里插入图片描述

注:被省略的着色器包括几何着色器和细分着色器,这在高级绘制中才会用到。比如mc中,SE 的 PTGI 着色器,利用几何着色器生成物体的遮光体积,帮助后续全局光照的运行。

渲染流水线小结

一图流:

在这里插入图片描述

注意:中间省略了一些操作,比如几何和细分着色器的处理。此外,后续包括深度测试,模板测试,背面剔除等测试与混合阶段。此外,图形并不是直接输出到屏幕,而是输出到帧缓冲中,这个我们后面细🔒

向GPU传递数据

在了解完渲染流水线之后,我们晓得了绘制的起点就是顶点数据的传输。其实不光能够传递顶点数据,我们还能够传输其他的数据,比如顶点的颜色,顶点的法线,顶点的纹理坐标等等。这一部分我们将通过GLEW提供的API,向GPU传递数据。

vbo

早期OpenGL是直接将顶点数据发送到GPU的,但是效率不高,而且难以管理。于是有了VBO的概念。VBO 全称 vertex buffer object,顶点缓存对象,用以缓存发送到着色器的顶点属性(可以是顶点坐标,顶点颜色,顶点法线等)。

VBO借鉴内存虚拟化的思想,一个显存中可以有多个VBO,他们缓存了不同的顶点数据,就好像多个线程都有其自己独立的运行内存一样,如图展示了有无VBO的显存管理方式:

在这里插入图片描述

vao

只有vbo还不能够很好的组织显存内的数据,因为vbo只规定了数据的二进制字节存储空间,而vbo并未规定数据如何解析。如图,一堆浮点数可以被以不同的方式进行解析:

在这里插入图片描述
于是产生了vao。vao 全称 vertex array object,顶点数组对象。vao规定了其对应vbo内的数据应该如何解析。此外,vao还负责找到与其对应的vbo在显存中的对应地址。

开始绘制三角形

知晓了渲染流水线,我们明白需要向 GPU 传顶点数据以使其绘制,而且要通过较为现代的 VAO+VBO 的解决方案进行数据交互。接下来我们开始绘制一个三角形。

我们传递两个信息,他们分别是:

  1. 三角形三个顶点的位置信息
  2. 三角形三个顶点的颜色信息

然后我们在顶点着色器中直接输出他们的位置和颜色到片元着色器,然后让光栅化帮我们对颜色进行插值,然后我们就可以看到彩色的三角形了!



使用GLuint进行对象引用

前面我们提到很多的 object ,比如 vao,vbo 等等。可是在 c 语言中并没有其对应类型的实现。事实上,为了跨平台,我们几乎所有的OpenGL的对象,都通过一个数字来对其进行引用

我们可以通过同样的数据类型(GLunit)来表示不同的对象:

GLuint vao;		// vao对象
GLuint vbo;		// vbo对象
GLuint program;	// 着色器对象

加载着色器程序

在往GPU里面传递数据之前,我们要告诉着色器程序我们传递的变量叫啥,比如我们传递顶点位置,我们需要在着色器中声明一个变量去接收顶点位置。可是,在此之前,我们先得有一个着色器程序!

着色器程序对象 是 【顶点着色器+片元着色器】 组成的一组程序,而不是指单个的着色器。

我们可以使用 glCreateShader 函数来产生单个的着色器对象 :

GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);

随后通过 glShaderSource 函数,将真正的着色器字符源码(通过第三个参数)传送给我们的着色器对象,随后我们编译着色器程序:

glShaderSource(vertexShader, 1, (const GLchar**)(指向代码字符串的指针), NULL);
glCompileShader(vertexShader);

注:glShaderSource的第二个参数是源码字符串数目,我们读进来之后把它都转成一行的字符串,所以我们填1即可,此外第四个参数暂时填NULL。

有时候我们希望知晓着色器程序是否编译正确,我们可以在 glCompileShader 之后,通过如下的代码检测编译结果:

// 容错
GLint success;
GLchar infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);   // 错误检测
if (!success)
{
   
	glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
	std::cout << "顶点着色器编译错误\n" << infoLog << std::endl;
	exit(-1);
}

我们对片元着色器也是如法炮制:

// 创建并且编译片段着色器
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, (const GLchar**)(&fpointer), NULL);
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);   // 错误检测
if (!success)
{
   
    glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
    std::cout << "片段着色器编译错误\n" << infoLog << std::endl;
    exit(-1);
}

在两个着色器都编译正常之后,我们创建【着色器程序】对象,并且链接两个着色器,链接完成之后,我们可以销毁两个着色器对象了:

// 链接两个着色器到program对象
GLuint shaderProgram = glCreateProgram(); 
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

// 删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

在了解这个流程之后,我们可以实现一个函数,传入两个路径,他就会读取对应的着色器,并且返回着色器程序对象。这个流程,在我们今后的代码中恐怕要频繁使用了,我们封装一下这些函数:

// 读取文件并且返回一个长字符串表示文件内容
std::string readShaderFile(std::string filepath)
  • 20
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值