一篇搞懂OpenGL中的着色器和如何使用

目录

异常问题:

一、OpenGL着色器工作原理

1.什么是着色器

2.什么是渲染管线

3.着色器的工作流程与原理

二、OpenGL着色器的创建和调用接口顺序

1.glCreateProgram(创建一个空的程序对象)

2.glCreateShader(要创建一个着色器对象)

3.glShaderSource(加载着色器代码资源)

4.glCompileShader(编译着色器代码)

5.glAttachShader(把着色器链接到程序对象)

6.glLinkProgram(连接程序对象)

7.glValidateProgram(检查程序对象中包含的可执行文件是否可行)

8.glDeleteShader(删除着色器)

9.glUseProgram(使用程序)

三、优化封装着色器的调用

1.把着色器代码写在一个文件,封装读取ParseShader

2.把编译着色器的流程封装CompileShader

3.把创建着色器的流程封装CreateShader

4.着色器的代码文件

5.使用着色器

四、画正方形引申出的缓存问题

五、OpenGL怎么调试和报错方法

六、总结


话不多说,我把我看的视频链接贴出来,下面的笔记是由视频学习和自己的补充而来。这次是(6-10)的笔记

跟着这个小哥的教学视频学的(YouTube原视频,科学上网AI字幕) ►                     http://bit.ly/2lt7ccM
这个是哔哩哔哩网站有人搬运的 ►https://www.bilibili.com/video/BV1MJ411u7Bc/?share_source=copy_web&vd_source=80ce9fa9cc5a33fdc2b9a467859dd047

在学习笔记中遇到了个奇怪的问题,有解决办法,但是至今未找到根源问题,我贴出来如果有遇到一样的,或者有解决方法的可以留言讨论一下,谢谢了

异常问题:

在最开始利用OpenGL画三角形的程序中,我三角形倒是画出来了,但是运行几秒钟时间之后就会报错,一个有关于igxelpicd32.dll的错,报错显然是未找到igxelpicd32.pdb这个调试需要用的中间文件,如下图:

 跟着网上的解决办法试了,比如勾选启用源服务器支持,然后把Microsoft符号服务器选上,之后再运行的时候呢,Vs会去找电脑上的缺少的一些库生成pdb调试,但是不起作用,然后就是去找官方文档社区,看过兼容性问题,也更新了驱动依然没有用,依旧是启动画出三角形之后运行几秒钟崩溃。

最后还是自己这样设置才可以,就是在NVIDIA控制面板把这个程序的图形处理器从集显设置为独立显卡,然后再运行程序就不会报错崩溃了,反复试过是可以的,我不知道为什么集显和独显的区别差异导致了这个现象,还请有头绪的大神可以指点一二!

由于我这样设置也不影响后续的代码和程序问题,所以就先这样解决,根源问题先放这里,接下来进入笔记正题。

一、OpenGL着色器工作原理

1.什么是着色器

OpenGL着色器是一种在图形渲染管线中用于执行特定计算的程序。着色器是一段运行在显卡上的小型程序,用于控制渲染管线中的特定阶段,如顶点着色器(Vertex Shader)、片段(像素)着色器(Fragment Shader)【我喜欢叫像素着色器,翻译可能不准确,反正记住它是管颜色的就对了】等。着色器的工作原理涉及到图形渲染的基本过程和可编程渲染管线的概念。

2.什么是渲染管线

图形渲染管线(Graphics Rendering Pipeline)是一个抽象概念,是一系列阶段,也就是一个用于将3D场景中的几何数据转换为最终的在电脑上显示的2D图像流程,注意是电脑上2D,但是我们其实看起来是3D,就像你在一张纸上画一个立体的正方体。一般的图形渲染管线包括以下阶段:

  1. 顶点着色器(Vertex Shader):负责处理每个输入顶点的位置、颜色等属性。可以执行坐标变换、法向量变换等操作。

  2. 图元装配(Primitive Assembly):将顶点转换成图元(如三角形)。

  3. 几何着色器(Geometry Shader):处理图元,并可以生成新的顶点或丢弃不需要的图元。

  4. 光栅化(Rasterization):将图元映射到屏幕上的像素。

  5. 片段着色器(Fragment Shader):对每个屏幕上的像素进行处理,计算其最终颜色。

  6. 输出合并(Output Merger):将片元的颜色输出到帧缓冲,最终形成图像。

在可编程渲染管线中,上述阶段中的一些或全部都可以由开发者自定义。这就是着色器的作用所在。这里我们就着重先来看一下顶点着色器和片段着色器这两个非常基础和重要的着色器:

  • 顶点着色器:处理顶点的位置和属性。
  • 片段着色器:处理最终的像素颜色。

这两个着色器使用类似于 C 语言的 GLSL(OpenGL Shading Language)语言编写。这个我们后续可以看看着色器到底语言上的样子,开发者可以编写这些着色器,然后在运行时将它们传递给 OpenGL,OpenGL 会将它们编译并链接到渲染管线中。

3.着色器的工作流程与原理

所以着色器是在什么时候编译创建和运行的呢?步骤如下:

  1. 编写着色器代码:开发者编写顶点着色器和片段着色器的代码,定义了顶点和像素的计算方式。

  2. 编译着色器:OpenGL 库将着色器代码编译成显卡可以理解的二进制代码。这里和我们程序编译不一样,调用OpenGL的接口编译,运行到接口代码的时候先编译然后放到GPU管线中去

  3. 创建着色器程序:开发者创建一个着色器程序,将编译后的顶点着色器和片元着色器链接在一起。就是和上面一起的,是使用着色器的应用

  4. 绑定着色器程序:在渲染图形之前,开发者告诉 OpenGL 使用特定的着色器程序。

  5. 传递数据:开发者将顶点数据、纹理等传递给着色器程序。就是笔记一中讲到的给GPU缓冲区传入数据

  6. 渲染:OpenGL 根据着色器程序的定义,对每个顶点和像素执行相应的计算,最终生成图像。

着色器的使用使得开发者可以非常灵活地控制图形渲染的过程,实现各种视觉效果和计算需求。 

二、OpenGL着色器的创建和调用接口顺序

着色器的创建调用到的接口流程可以依靠上面提到的工作原理结合在一起方便记忆。下面的很多解释来自于官方文档,加上本作者自己的理解,所以结合OpenGL官方文档学习很更准确,印象更深。

1.glCreateProgram(创建一个空的程序对象)

glCreateProgram创建一个空的程序对象,并返回一个可以引用它的非零值。程序对象是可以附加着色器对象的对象。这提供了一种机制来指定将被链接以创建程序的着色器对象。它还提供了一种检查将用于创建程序的着色器兼容性的方法(例如,检查顶点着色器和片段着色器之间的兼容性)。当不再需要作为程序对象的一部分时,可以分离着色器对象。这里我的理解就是单独开一个程序,然后在里面创建可编写着色器程序,这样就可以方便管理和剥离

2.glCreateShader(要创建一个着色器对象)

要创建一个什么类型的着色器,我们知道着色器有很多很多的,但是现在着重就是两个GL_VERTEX_SHADER和GL_FRAGMENT_SHADER

3.glShaderSource(加载着色器代码资源)

两个着色器使用类似于 C 语言的 GLSL(OpenGL Shading Language)语言编写,那传入的时候就可以是string放到数组里然后传指针进去就行

void glShaderSource(GLuint shader,GLsizei count,const GLchar **string,const GLint *length);

  • shader:要指定源代码的着色器对象的标识符。也就是索引,因为GPU管线要知道是哪个着色器,用过这个索引,创建时会返回。
  • count:指定 string 数组中的元素数量。也就是一个着色器可以加载多套着色器程序,这个后面复杂的渲染会用到
  • string:一个指向包含源代码的字符串数组的指针。就是着色器代码要求是字符串数组指针。
  • length:一个指向整数数组的指针,用于指定每个字符串的长度,如果传入 NULL,则表示每个字符串都是以空字符(null-terminated)结尾的。

看文档可以知道,传入的着色器代码是:指向包含要加载到着色器中的源代码的字符串的指针数组。

当然我们这里可以这么写

	std::string source = 
		"#version 330 core\n"
		"\n"
		"layout(location = 0) in vec4 position;\n"
		"\n"
		"void main()\n"
		"{\n"
			"gl_Position = position;\n"
		"};\n"

但是这么写十分麻烦,而且稍微写错就会出问题,还不能文件化管理,稍后我们会把着色器的代码改为存到一个文件里编写,通过函数给它读出来

4.glCompileShader(编译着色器代码)

那怎么确定有没有编译成功呢

	glCompileShader(id);
    // 这里讲述如何从GL接口中拿到参数值以及抛出错误,因为有些gl接口是没有返回值的
	int result;
	//i是id,v是来自于glEnum的参数,以及一个接收结果的参数
	glGetShaderiv(id, GL_COMPILE_STATUS, &result);
	if (result == GL_FALSE)
	{
		int length;
		glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
		//这里动态分配一个char数组
		char* message = (char*)alloca(length * sizeof(char));
		glGetShaderInfoLog(id, length, &length, message);
		std::cout << "failed to compileShader" <<
			(type == GL_VERTEX_SHADER ? "VertexShader" : "fragmentShader") << std::endl;
		std::cout << message << std::endl;
		glDeleteShader(id);
		return 0;
	}
    return id;

5.glAttachShader(把着色器链接到程序对象)

把着色器链接到程序,因为一个程序中可以有多个着色器,所以也是流程中方便管理,相当于绑定。

6.glLinkProgram(连接程序对象)

这里就是把着色器传到GPU去,因为GPU其实可能是会有默认着色器程序的,这里我们把程序link之后,会把对应类型的着色器链接到对应的可执行文件,这样GPU在渲染的时候,就会用到真正的你自己编写的着色器了。

7.glValidateProgram(检查程序对象中包含的可执行文件是否可行)

glValidateProgram检查程序中包含的可执行文件是否可以在当前OpenGL状态下执行。验证过程生成的信息将存储在程序的信息日志中。验证信息可以由空字符串组成,也可以是包含关于当前程序对象如何与当前OpenGL状态的其余部分交互的信息的字符串。这为OpenGL实现者提供了一种方式来传达更多信息,说明当前程序为什么效率低下、次优、无法执行等等。

验证操作的状态将作为程序对象状态的一部分存储。如果验证成功,此值将设置为GL_TRUE,否则设置为GL_FALSE。它可以通过调用带有参数的glGetProgram程序和GL_VALIDATE_STATUS来查询。若验证成功,则保证程序在给定的当前状态下执行。否则,保证程序不会执行。

也就是说你把自己编写的着色器给GPU的时候,OpenGL的接口可以帮助你判断是否可以用,是好是坏,所以你写的着色器不是替换,而是选择使用。

8.glDeleteShader(删除着色器)

删除着色器,这里因为已经连接到程序对象中了,所以是等着和程序对象分离之后才会删除,不影响渲染的时候的上下文。

9.glUseProgram(使用程序)

一切准备就绪之后,就可以使用这个我们包含了多个着色器程序的程序对象了。

三、优化封装着色器的调用

这里就涉及c++的封装性质,希望大家学习大神的封装思维,有效编写有助于帮助我们把代码写好。

1.把着色器代码写在一个文件,封装读取ParseShader

//着色器类型的枚举
enum class SHADERTYPE
{
	NONE = -1,
	VERTEXSHADER = 0,
	FRAGMENTSHADER = 1
};

//储存着色器资源代码的结构体
struct shaderProgramSource
{
	std::string VertexSource;
	std::string FragmentSource;
};

//动态划分一个文件中多个Shader源码的函数
static shaderProgramSource ParseShader(const std::string& filePath)
{
	std::fstream stream(filePath);
	std::string line;
	std::stringstream ss[2];
	SHADERTYPE type = SHADERTYPE::NONE;
	while (getline(stream, line))
	{
		if (line.find("#shader") != std::string::npos)
		{
			if (line.find("vertex") != std::string::npos)
				// this is vertexshader parse
				type = SHADERTYPE::VERTEXSHADER;
				
			else if ((line.find("fragment") != std::string::npos))
				// this is fragmentshader parse
				type = SHADERTYPE::FRAGMENTSHADER;
		}
		else
		{
			ss[(int)type] << line << '\n';
		}
	}
	return { ss[0].str(), ss[1].str() };
}

2.把编译着色器的流程封装CompileShader

static unsigned int CompileShader(unsigned int type, const std::string& source)
glCreateShader
glShaderSource
glCompileShader
//编译着色器
//着色器的加载很可能来源于一些文件或者是一些数据,所以有source参数一说
static unsigned int CompileShader(unsigned int type, const std::string& source)
{
	unsigned int id = glCreateShader(type);
	const char* src = source.c_str();
	//	shader
	//	Specifies the handle of the shader object whose source code is to be replaced.

	//	count
	//	Specifies the number of elements in the stringand length arrays.

	//	string
	//	Specifies an array of pointers to strings containing the source code to be loaded into the shader.

	//	length
	//	Specifies an array of string lengths.
	glShaderSource(id, 1, &src, nullptr);
	glCompileShader(id);

	// 这里讲述如何从GL接口中拿到参数值以及抛出错误,因为有些gl接口是没有返回值的
	int result;
	//i是id,v是来自于glEnum的参数,以及一个接收结果的参数
	glGetShaderiv(id, GL_COMPILE_STATUS, &result);
	if (result == GL_FALSE)
	{
		int length;
		glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
		//这里动态分配一个char数组
		char* message = (char*)alloca(length * sizeof(char));
		glGetShaderInfoLog(id, length, &length, message);
		std::cout << "failed to compileShader" <<
			(type == GL_VERTEX_SHADER ? "VertexShader" : "fragmentShader") << std::endl;
		std::cout << message << std::endl;
		glDeleteShader(id);
		return 0;
	}
	return id;
}

3.把创建着色器的流程封装CreateShader

                        static unsigned int CreateShader(const std::string& vertexShader,

                                                const std::string& fragmentShader)

glCreateProgram
CompileShader(unsigned int type, const std::string& source)
  glAttachShader
glLinkProgram
glValidateProgram
glDeleteShader
//创建着色器
static unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
	//typedef unsigned int GLuint; 
	//这里我们知道,其实OpenGL定义了一系列自己的类型,
	//可以快速调用,但这里还是用纯C++的类型来使用
	unsigned int program = glCreateProgram();
	unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexShader);
	unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentShader);

	glAttachShader(program, vs);
	glAttachShader(program, fs);
	glLinkProgram(program);
	glValidateProgram(program);

	glDeleteShader(vs);
	glDeleteShader(fs);

	return program;
}

4.着色器的代码文件

#shader vertex
#version 330 core

layout(location = 0) in vec4 position;

void main()
{
    gl_Position = position;
};

#shader fragment
#version 330 core

layout(location = 0) out vec4 color;

void main()
{
    color = vec4(1.0, 0.0, 0.0, 1.0);
}

像素着色器中的vec4(r,g,b,a)这四个参数就是大名鼎鼎的rgba

5.使用着色器

	shaderProgramSource source = ParseShader("res/shaders/Basic.shader");
	std::cout << "VertexShader" << std::endl;
	std::cout << source.VertexSource << std::endl;
	std::cout << "FragmentShader" << std::endl;
	std::cout << source.FragmentSource << std::endl;
	unsigned int shaderID = CreateShader(source.VertexSource, source.FragmentSource);
	glUseProgram(shaderID);

四、画正方形引申出的缓存问题

这里我们以为的画正方形是四个点,然后顶点着色器定位之后,然后就填充就画出来了,但是不是的,所有的图像其实都是在画三角形,三角形就是一个基础图像,就比如正方形其实是如下画的,很明显有两个三角形的时候,有顶点是重复的,我们当然可以存6个顶点来画,但是这样一个还好,如果数量多了就是很大的消耗了,所以优化就是要把重复的顶点只存一个,但是这样就有新的问题,着色器怎么知道哪些顶点是一个三角形的,顺序是怎么样的,这里就引申出了新的方法,就是给顶点的属性再加一个索引,放到索引缓冲区,这样着色器就知道怎么取那些顶点了。

    //声明一个float数组,顶点属性列表
    float positions[] = {
        -0.5f, -0.5f, //0
         0.5f, -0.5f, //1
         0.5f,  0.5f, //2
        -0.5f,  0.5f  //3
    };
    unsigned int indices[]{
        0,1,2,
        2,3,0
    };

    //这里声明一个缓冲区
    unsigned int buffer;
    glGenBuffers(1, &buffer);
    //声明之后需要绑定,因为在GPU中的缓冲区都是有编号的,或者说是有管理的
    glBindBuffer(GL_ARRAY_BUFFER, buffer);
    //现在要给一个缓冲区塞数据,每个接口函数都可以通过说明文档来查看参数的意义和使用
    glBufferData(GL_ARRAY_BUFFER, 6 * 2 * sizeof(float), positions, GL_STATIC_DRAW);

    //可用的顶点从第几个开始
    glEnableVertexAttribArray(0);
    //index:我们从第几个顶点开始访问
    //size:一个顶点属性值里面有几个数值
    //type:每个值的数据类型
    //normalized:是否要转化为统一的值
    //stride:步幅 每个顶点属性值的大小,就是到下一个顶点的开始的字节偏移量。
    //pointer:在开始访问到顶点属性值的时候开始的指针位置(注意和Index的区别)
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(float) * 2, 0);

    //这里声明一个索引缓冲区
    unsigned int ibo;
    glGenBuffers(1, &ibo);
    //声明之后需要绑定,因为在GPU中的缓冲区都是有编号的,或者说是有管理的,GL_ELEMENT_ARRAY_BUFFER用到这个枚举名
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
    //现在要给一个缓冲区塞数据,每个接口函数都可以通过说明文档来查看参数的意义和使用
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, 6 * sizeof(unsigned int), indices, GL_STATIC_DRAW);

        注意当我们使用索引缓冲区之后,我们就不是DrawArrays了,而是DrawElement了

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);

五、OpenGL怎么调试和报错方法

我们用一个最简单的问题来调试报错的方法:unsigned int 和int的区别

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);

在这里我们只需要吧GL_UNSIGNED_INT改成GL_INT

glDrawElements(GL_TRIANGLES, 6, GL_INT, nullptr);

就会画不出图案,而且OpenGL不会抛出任何错误,需要我们自己来。接下来讲述如何从GL接口中拿到参数值以及抛出错误,因为有些gl接口是没有返回值的。

我们可以增加断言,这样出错就会停止运行,x就是你要执行的OpenGL函数

#define ASSERT(x) if(!x) __debugbreak();

由于OpenGL的接口保存着执行到现在所有的错误,在拿错误的时候,需要先清理,然后清理完成之后,再调用OpenGL的接口执行之后,我们再去拿错误,如果有就断言并且抛出错误码和错误内容,如果没有我们就继续执行,这时候就可以写一个宏,x是你要执行的OpenGL的函数

#define GLCall(x) GLClearError();\
	x;\
	ASSERT(GLLogCall(#x, __FILE__, __LINE__));

然后我们再实现一下

//清除OpenGL报错的函数
static void GLClearError()
{
	//get一次就清除一条,一直get到为0条错误为止
	while (glGetError());
}

//获取OpenGL错误信息的函数
static bool GLLogCall(const char* function, const char* file, const int line)
{
	while (GLenum error = glGetError())
	{
		std::cout << "[OpenGL Error] (" << error << ")" 
			<< "Function:" << function 
			<< "file Name(" << file << ")"
			<< "line(" << line << ")"  << std::endl;
		return false;
	}
	return true;
}

最后我们只需要简单的方法就可以使用了

GLCall(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr));
GLCall(任何OpenGL函数);

六、总结

我们通过编写着色器完成了画正方形,而且新增了抛错功能。

我会把现阶段的主要代码文件附上,可以总体看看效果

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值