OpenGL着色器透视变换实例-通过旋转平移调试着色器

OpenGL 着色器新手样例 带透视变换和旋转平移缩放

  • OpenGL着色器样例 - 最简单的顶点着色器 + 片元着色器
    • 头文件和宏定义
    • 全局变量部分
    • 读取着色器
      • 从文本中读取着色器代码
      • 初始化着色器
      • 初始化VBO
      • 计算相机位姿
    • 创建文本
    • 画坐标轴
    • 初始化函数
    • 渲染函数
    • 键盘响应函数
    • 鼠标操作
    • main 函数
    • 顶点着色器
    • 片元着色器
  • 如何调试
      • 着色器没有编译成功
      • 着色器没有加载,或者绑定错误,或者参数错误
      • 渲染错误、不在视野中或颜色不对
      • 渲染都正确,但是程序没有刷新
  • 实战演练
  • 总结

OpenGL着色器样例 - 最简单的顶点着色器 + 片元着色器

先上结果图:
OpenGL着色器透视变换实例-通过旋转平移调试着色器_第1张图片
再上源代码:VS2015工程文件含库
https://download.csdn.net/download/xiongyuanxy/11126556

学习OpenGL的同学可能被着色器着实恶心到了,上手难,还不容易调试,各种莫名其妙的错误。屏幕一片漆黑,到底是什么愿意呢?有以下可能:

  • 着色器没有编译成功
  • 着色器没有加载
  • 着色器没有绑定变量
  • 着色器没有被调用,或者调用参数不对
  • 渲染错误
  • 渲染正确但是你看不到结果,有可能不在视野中
  • 渲染正确但是颜色是黑的,你看不到结果
  • 渲染都正确,但是程序没有刷新

怎么解决呢?最好的办法就是通过跟传统OpenGL管线对比,看看到底哪里出错了。
网上其他大神给出了很多着色器的教学帖子,我在此抛砖引玉,给出一个简单的着色器样例。为了方便调试,我们把着色器的结果输出到屏幕。可以通过对比传统管线渲染结果来验证。画个坐标轴,输出相机位姿参数,并允许用户通过鼠标拖拽来旋转缩放平移。找出问题,看看到底是哪里出的错。当然,这里面用到的相机姿态估计,几何变换,打印字体,鼠标操作等等小功能也是很实用的功能。

为了尽量减少学习难度,我把代码整合到一个文件中,并根据功能按顺序逐步贴出来。你可以简单的把它们合并之后,就能编译运行。我的代码中自带注释,也可以通过我的描述了解每段代码的含义。
后面还会给出顶点着色器VertexShader.vertFragmentShader.frag的代码。
先给出ShaderDemo.cpp文件的代码

头文件和宏定义

最简单的OpenGL依赖库,包括

  • freeglut 3.0.0 当前最新版OpenGL函数库
  • glew 2.1 当前最新版OpenGL扩展库,用于加载着色器以及一些缓存
  • glm 0.9.9.5 当前最新版glm库,用于矩阵计算
    以上部分也可以从我之前发布的运行库中下载,当然你也可以直接用源码包中的,都一样:
    https://download.csdn.net/download/xiongyuanxy/11120380
    全部是官网下载的源码手工编译,编译工具是CMake结合VS2015,包含Win64 下的Debug和Release两个版本。
    使用方法就是和工程文件丢一起就行了,方便快捷。当然,你需要手动添加静态链接库,本文中使用了freeglut.lib和glew32.lib两个静态链接库,如果是debug版本,需要在后面加上后缀d,就是freeglutd.lib和glew32d.lib。
    演示程序窗口大小是800*800。
#include "Dependencies\glew\glew.h"
#include "Dependencies\freeglut\freeglut.h"
#include "Dependencies\glm\glm.hpp"
#include "Dependencies\glm\gtc\matrix_transform.hpp"
#include 
#define PI 3.161592453589793
#define ROTFACTOR	0.2     // degrees rotation per pixel of mouse movement
#define TRANSLATEFACTOR	0.05     // units of translation per pixel of mouse movement
#define WND_WIDTH 800
#define WND_HEIGHT 800
using namespace std;

全局变量部分

不用全局变量的程序员不是一个好学生。我比较懒,把全局变量丢这里,反正是为了Demo,也不必在乎美观和代码风格。不必在乎我的驼峰变量名,你可以使用下划线风格。

GLuint vShader, fShader;//顶点/片段着色器对象  
GLuint vaoHandle;// VAO对象
GLuint vboHandles[2]; //VBO对象
GLuint programHandle; // 着色器程序对象
GLuint base; //写字用
int MouseX;//鼠标上次点击的位置横坐标,以屏幕左上角位原点
int MouseY;//鼠标上次点击的位置纵坐标,以屏幕左上角位原点
double angleRoll;//鼠标右键拖拽旋转方向,绕x轴旋转
double angleYaw;//鼠标右键拖拽旋转方向,绕y轴旋转
double anglePitch;//鼠标右键拖拽旋转方向,绕z轴旋转
int pressedButton;//按键
double viewWidth = 2.0; // 视锥近裁面宽度,区间[-1, 1]
double viewHeight = 2.0; // 视锥近裁面高度,区间[-1, 1]
double fieldOfView = 60.0 / 180 * PI; // 相机视角60度,大约等于手机摄像头视角(长边)
double focalLength = viewHeight/2.0/tan(fieldOfView/2.0); // 相机焦距
double cutOffNear = 0.1;//近裁面距离
double cutOffFar = 1000;//远裁面距离
glm::vec4 cameraTranslate(0, 0, 0, 1);//鼠标中键拖拽根据相机坐标系平移
glm::mat4 modelViewMatrix(1.0f);//模型视图变换矩阵
glm::mat4 perspectiveMatrix(1.0f);//透视矩阵
glm::vec4 cameraPosition;//相机位置
glm::vec4 xDirection;//相机x轴
glm::vec4 yDirection;//相机y轴
glm::vec4 zDirection;//相机z轴
//顶点位置数组
float positionData[] = {
	-0.5f,-0.5f,0.0f,1.0f,
	0.5f,-0.5f,0.0f,1.0f,
	0.5f,0.5f,0.0f,1.0f,
	-0.5f,0.5f,0.0f,1.0f
};
//顶点颜色数组  
float colorData[] = {
	1.0f, 0.0f, 0.0f,1.0f,
	0.0f, 1.0f, 0.0f,1.0f,
	0.0f, 0.0f, 1.0f,1.0f,
	1.0f,1.0f,0.0f,1.0f
};

特别需要注意的是,全局变量最后部分给出了一组顶点,是按照逆时针顺序排列的4组数。每组顶点的坐标包含4个数,分别是 x,y,z,w 分量。w通常设置成1, 方便矩阵乘法计算。
顶点颜色数组也是4组数,每组包含四个数,分别是r,g,b,a分量,与顶点位置一一对应。这样你就能在调试的时候有针对性的根据颜色来看计算结果

读取着色器

从文本中读取着色器代码

网上大神们读取着色器部分写的很详细了,我就直接拿来用了。简单的文本读取函数

/*
读入字符流
*/
char *textFileRead(const char *fn)
{
	FILE *fp;
	char *content = NULL;
	int count = 0;
	if (fn != NULL)
	{
		fopen_s(&fp, fn, "rt");
		if (fp != NULL)
		{
			fseek(fp, 0, SEEK_END);
			count = ftell(fp);
			rewind(fp);
			if (count > 0)
			{
				content = (char *)malloc(sizeof(char) * (count + 1));
				count = fread(content, sizeof(char), count, fp);
				content[count] = '\0';
			}
			fclose(fp);
		}
	}
	return content;
}

初始化着色器

本样例中着色器包含两个类型,顶点着色器和片元着色器。初始化的时候需要给出他们的代码文件名。代码略长,但是这是摘抄自大神们的工作,无需了解含义。大神已经写得很详细了。

  1. 查看并显示显卡信息
  2. 编译着色器
  3. 链接着色器对象

在完成的时候,我注释掉了大神的使用着色器程序的那行代码,因为我们为了调试需要动态加载着色器程序,不希望着色器上来就接管渲染管线。反复编译链接着色器并不是好的操作,因为这会大大降低程序效率。理论上讲,你只需要在程序一开始编译链接着色器,然后再程序关闭之前释放掉他们就好了。本例中并没有delete着色器和程序,因为应用程序会帮我们做这些清理工作,对于生命周期包含整个程序的资源,我们没必要那么认真。唯一的问题是,你如果不小心x掉了程序,VS2015调试会中断,所以请按ESC键正常退出程序,我在后面绑定了ESC键的响应事件。

/*
初始化着色器
*/
void initShader(const char *VShaderFile, const char *FShaderFile)
{
	//1、查看显卡、GLSL和OpenGL的信息  
	const GLubyte *vendor = glGetString(GL_VENDOR);
	const GLubyte *renderer = glGetString(GL_RENDERER);
	const GLubyte *version = glGetString(GL_VERSION);
	const GLubyte *glslVersion = glGetString(GL_SHADING_LANGUAGE_VERSION);
	cout << "显卡供应商   : " << vendor << endl;
	cout << "显卡型号     : " << renderer << endl;
	cout << "OpenGL版本   : " << version << endl;
	cout << "GLSL版本     : " << glslVersion << endl;

	//2、编译着色器  
	//创建着色器对象:顶点着色器  
	vShader = glCreateShader(GL_VERTEX_SHADER);
	//错误检测  
	if (0 == vShader)
	{
		cerr << "ERROR : Create vertex shader failed" << endl;
		exit(1);
	}

	//把着色器源代码和着色器对象相关联  
	const GLchar *vShaderCode = textFileRead(VShaderFile);
	const GLchar *vCodeArray[1] = { vShaderCode };

	//将字符数组绑定到对应的着色器对象上
	glShaderSource(vShader, 1, vCodeArray, NULL);

	//编译着色器对象  
	glCompileShader(vShader);

	//检查编译是否成功  
	GLint compileResult;
	glGetShaderiv(vShader, GL_COMPILE_STATUS, &compileResult);
	if (GL_FALSE == compileResult)
	{
		GLint logLen;
		//得到编译日志长度  
		glGetShaderiv(vShader, GL_INFO_LOG_LENGTH, &logLen);
		if (logLen > 0)
		{
			char *log = (char *)malloc(logLen);
			GLsizei written;
			//得到日志信息并输出  
			glGetShaderInfoLog(vShader, logLen, &written, log);
			cerr << "vertex shader compile log : " << endl;
			cerr << log << endl;
			free(log);//释放空间  
		}
	}

	//创建着色器对象:片断着色器  
	fShader = glCreateShader(GL_FRAGMENT_SHADER);
	//错误检测  
	if (0 == fShader)
	{
		cerr << "ERROR : Create fragment shader failed" << endl;
		exit(1);
	}

	//把着色器源代码和着色器对象相关联  
	const GLchar *fShaderCode = textFileRead(FShaderFile);
	const GLchar *fCodeArray[1] = { fShaderCode };
	glShaderSource(fShader, 1, fCodeArray, NULL);

	//编译着色器对象  
	glCompileShader(fShader);

	//检查编译是否成功  
	glGetShaderiv(fShader, GL_COMPILE_STATUS, &compileResult);
	if (GL_FALSE == compileResult)
	{
		GLint logLen;
		//得到编译日志长度  
		glGetShaderiv(fShader, GL_INFO_LOG_LENGTH, &logLen);
		if (logLen > 0)
		{
			char *log = (char *)malloc(logLen);
			GLsizei written;
			//得到日志信息并输出  
			glGetShaderInfoLog(fShader, logLen, &written, log);
			cerr << "fragment shader compile log : " << endl;
			cerr << log << endl;
			free(log);//释放空间  
		}
	}

	//3、链接着色器对象  
	//创建着色器程序  
	programHandle = glCreateProgram();
	if (!programHandle)
	{
		cerr << "ERROR : create program failed" << endl;
		exit(1);
	}
	//将着色器程序链接到所创建的程序中  
	glAttachShader(programHandle, vShader);
	glAttachShader(programHandle, fShader);
	//将这些对象链接成一个可执行程序  
	glLinkProgram(programHandle);
	//查询链接的结果  
	GLint linkStatus;
	glGetProgramiv(programHandle, GL_LINK_STATUS, &linkStatus);
	if (GL_FALSE == linkStatus)
	{
		cerr << "ERROR : link shader program failed" << endl;
		GLint logLen;
		glGetProgramiv(programHandle, GL_INFO_LOG_LENGTH,
			&logLen);
		if (logLen > 0)
		{
			char *log = (char *)malloc(logLen);
			GLsizei written;
			glGetProgramInfoLog(programHandle, logLen,
				&written, log);
			cerr << "Program log : " << endl;
			cerr << log << endl;
		}
	}
	else//链接成功,在OpenGL管线中使用渲染程序  
	{
		//glUseProgram(programHandle); //这里略有不同,我们并不一开始就加载程序,而是根据需要动态加载
	}
}

初始化VBO

VBO是啥我也不大懂,翻译过来就是顶点缓存对象。因为我们上面注册了一些顶点,需要为他们分配空间,绑定顶点缓存。我把这些顶点对象变量改成了全局变量,就是为了方便动态的绑定和解绑。

/*
初始化VBO
*/
void initVBO()
{
	//绑定VAO
	glGenVertexArrays(1, &vaoHandle);
	glBindVertexArray(vaoHandle);
	glGenBuffers(2, vboHandles);
	// Create and populate the buffer objects  
	GLuint positionBufferHandle = vboHandles[0];
	GLuint colorBufferHandle = vboHandles[1];

	//绑定VBO以供使用  
	glBindBuffer(GL_ARRAY_BUFFER, positionBufferHandle);
	//加载数据到VBO  
	glBufferData(GL_ARRAY_BUFFER, 16 * sizeof(float),
		positionData, GL_STATIC_DRAW);

	//绑定VBO以供使用  
	glBindBuffer(GL_ARRAY_BUFFER, colorBufferHandle);
	//加载数据到VBO  
	glBufferData(GL_ARRAY_BUFFER, 16 * sizeof(float),
		colorData, GL_STATIC_DRAW);

	glEnableVertexAttribArray(0);//顶点坐标  
	glEnableVertexAttribArray(1);//顶点颜色  

								 //调用glVertexAttribPointer之前需要进行绑定操作  
	glBindBuffer(GL_ARRAY_BUFFER, positionBufferHandle);
	glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, (GLubyte *)NULL);
	glBindBuffer(GL_ARRAY_BUFFER, colorBufferHandle);
	glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0, (GLubyte *)NULL);
}

计算相机位姿

OpenGL有个很好的特性,就是投影矩阵自带所有相机信息,也就是外参和部分内参,当然,需要你去计算。具体理论我就不讲了,你可以从这里找到更详细的信息:
http://www.songho.ca/opengl/gl_projectionmatrix.html
在这里插入图片描述
左边的图是普通透视,右边的图是正交透视,通常我们只用普通透视就够了。需要注意的是,这个坐标系是相机的不是世界坐标。OpengGL在画物体的时候,通过矩阵变换把世界坐标变为相机坐标。假设世界坐标是Pw,投影变换是M,那么该点在相机空间里的坐标Pc为:

** Pc = M* Pw **

在本例中,Pc和Pw都是glm::vec4 类型的4维向量,最后一维补充1方便矩阵计算。
M是glm::mat4类型的4维矩阵。一个典型的M如下:
[
0.707106769, -0.408248276, 0.577350259, 0.000000000,
0.000000000, 0.816496551, 0.577350259, 0.000000000,
-0.707106769, -0.408248276, 0.577350259, 0.000000000,
0.000000000, 0.000000000, -4.33012676, 1.000000000
]
这就是我们程序初始化后得到的相机参数,因为我一开始就把相机放在了(2.5, 2.5, 2.5,1)的世界坐标处,往原点看。这个矩阵,左上角的3x3子矩阵是普通的投影变换,包括了旋转和缩放,不包含平移。平移是通过移动最下方的向量来表示的。你可以计算下上面这个矩阵对不对。听我来给你分析:

  • 矩阵的左上方3x3子阵行列式为1,说明对于相机来说没有缩放。
  • 矩阵的左上方3x3子阵行列都互相正交(任意两列或两行的向量乘积为0),说明这是正交变换,图像没有扭曲。
  • 矩阵的左上方3x3子矩阵每行每列的三个数平方和为1,结合上面的两条,证明了这是个单位正交帧。
  • 矩阵的最下方表示了物体在相机视野中的位移量是sqrt(2.52.5+2.52.5+2.5*2.5),z轴负方向,x和y轴没有位移,所以物体居中。
    咦,屏幕前的那个小伙伴不相信,端出了计算器开始算了。告诉你个小窍门,用谷歌搜索框输入上面算式就验证下就知道了。
  • 最右边一列是固定的 0,0,0,1,是为了矩阵计算而填充,不需要修改。
    你可能奇怪了,既然没有缩放,为何图像有大小之分呢?答案很简单,大小是因为远近造成的,图像本身没有缩放。你把左上3x3矩阵做上三角QR分解,就能得到一个单位阵,说明相机没有切变、缩放、焦距不同的问题。当然这不是我们的要点。我们关心的是怎么计算相机姿态,也就是相机的位置以及坐标系(转换为我们能够理解的世界坐标系)。
    已知相机投影矩阵,怎么求相机的物理位置?把上面矩阵求逆来使用就是了。
    Pw = M-1* Pc
    用逆矩阵乘以原点坐标就能得到相机的世界坐标,乘以xyz单位向量,就能得到相机视角的右上后三个方向的终点坐标。用这些终点坐标减去相机的坐标就能得到相机的右上后三方向单位向量。
    注意:这三个方向向量并不需要归一化,也不必两两正交。如果在OpenGL体系下,他们自动就是两两正交的单位向量,因为OpenGL的虚拟相机是完美的,只存在double精度误差。如果你要做光线跟踪、图像配准、真实图像的相机内参估算,那么就要用这三个向量做扭曲分析。
    我们在这里计算这三个向量,就是为了判断我们的相机位置和着色器的渲染位置有没有偏差,出没出错。具体出错例子后面会补充。

算完视图变换矩阵还不够,还要算透视矩阵。透视矩阵就是说把三维的相机坐标点映射到2位的相机屏幕。相机屏幕的位置位于相机前焦距处(注意不是近裁面)。相机的视线中心和屏幕中心(光心)连线就是相机的z轴负方向。从光心往右就是x轴正方向,往上就是y轴正方向。这三个向量的世界坐标计算法上面已经给出了。理论上你可以用这个方法求得任意一点在相机坐标系内的坐标,射线方法就是从相机引出一条射线r(世界坐标系),以相机位起点,投影屏幕点位终点。假设窗口大小800800,那么屏幕中点位置(400,400)。屏幕被归一化到22,也就是从-1到+1,屏幕中心坐标(0,0),焦距 f = 1.0 / tan(fov/2),根据相似三角形, 我们可以很容易算出物体的真实距离。
d / |r| = z / f
其中z就是物体的深度。OpenGL会缓存这个深度,如果z小于近裁面,或者大于远裁面,就会被裁掉。现在知道为何你花了半天渲染的东西为何总是一片漆黑了吧?

当然这么计算物体位置太复杂了,OpenGL的深度信息也不是一个线性函数,而是与近裁面和远裁面相关的比例函数。
具体请参考
https://learnopengl.com/Advanced-OpenGL/Depth-testing
结论拿来:
在这里插入图片描述
好了不折腾了,明白了原理,并不等于我们要这么算。库函数里有现成的,拿来即可:
perspectiveMatrix = glm::perspective(fieldOfView, viewHeight / 2.0, cutOffNear, cutOffFar);
glGetFloatv(GL_MODELVIEW_MATRIX, &modelViewMatrix[0][0]);
上面两个函数分别得到了相机的投影矩阵和视图变换矩阵。其中,第一个透视矩阵运算需要用到视角度数,缩放比例(本例中使用焦平面高度一半,就是1,无缩放)近裁面和远裁面。第二个模型变换矩阵直接拿出来就好了,而且这是实时的,根据你当前视角变换得到的。
下面是相机位姿的代码

/*
计算相机位姿
*/
void getCameraFromModelView()
{
	//分别计算透视矩阵和视图矩阵
	perspectiveMatrix = glm::perspective(fieldOfView, viewHeight / 2.0, cutOffNear, cutOffFar);
	glGetFloatv(GL_MODELVIEW_MATRIX, &modelViewMatrix[0][0]);
	//计算相机逆矩阵
	glm::mat4 modelViewMatrixInverse = glm::inverse(modelViewMatrix);
	//计算相机位置
	cameraPosition = modelViewMatrixInverse * glm::vec4(0.0, 0.0, 0.0, 1.0);
	//相机参考系的三个坐标轴
	xDirection = modelViewMatrixInverse * glm::vec4(1, 0, 0, 1) - cameraPosition;
	yDirection = modelViewMatrixInverse * glm::vec4(0, 1, 0, 1) - cameraPosition;
	zDirection = modelViewMatrixInverse * glm::vec4(0, 0, 1, 1) - cameraPosition;
}
/*
重置相机位姿
*/
void resetCamera()
{
	//切换矩阵并设置视锥透视参数
	glMatrixMode(GL_PROJECTION);
	glLoadIdentity();//设为单位矩阵
	double scale = cutOffNear / focalLength;
	double xmax = scale * viewWidth / 2;
	double ymax = scale * viewHeight / 2;
	glFrustum(-xmax, xmax, -ymax, ymax, cutOffNear, cutOffFar);
	//切换矩阵并设置视图参数
	glMatrixMode(GL_MODELVIEW);
	glLoadIdentity();//设为单位矩阵
	//初始化透视矩阵,从相机位置看向世界原点,以物体y轴为上方向
	gluLookAt(2.5, 2.5, 2.5,//从相机位置看
		0, 0, 0,//看往世界原点
		0, 1, 0);//相机正上方向采用物体上方向,虽然不精确,但是待会儿会重算
	getCameraFromModelView();
}

创建文本

我们需要把相机的位置实时的打印出来,这样就能观看我们的着色器渲染是否正确。因为我们已知着色顶点的坐标,通过对比相机坐标,就能知道我们画的位置对不对。
文本有两种打印方式,跟着物体走的,和不跟着物体走的。这取决于你写字之前是否重置视图矩阵。当然,一些必要的压栈和弹栈操作是要的。
代码如下:

/*
*创建位图字体
*/
GLvoid BuildFont(GLvoid)
{
	HFONT font;		// 字体句柄
	HFONT oldfont;

	base = glGenLists(256);		// 创建显示列表
	font = CreateFont(-16,		// 字体高度
		0,		// 字体宽度
		0,		// 字体的旋转角度
		0,		// 字体底线的旋转角度
		FW_NORMAL,// 字体重量
		FALSE,	// 是否使用斜体
		FALSE,	// 是否使用下划线
		FALSE,	// 是否使用删除线
		ANSI_CHARSET,	// 设置字符集
		OUT_TT_PRECIS,	// 输出精度
		CLIP_DEFAULT_PRECIS,	// 剪裁精度
		ANTIALIASED_QUALITY,	// 输出质量
		FF_DONTCARE | DEFAULT_PITCH,
		LPCWSTR("Arial"));	// 字体名称
	oldfont = (HFONT)SelectObject(wglGetCurrentDC(), font);	// 选择我们需要的字体
	wglUseFontBitmaps(wglGetCurrentDC(), 0, 256, base);		// 创建显示列表,绘制从ASCII码为32-128的字符
	SelectObject(wglGetCurrentDC(), oldfont);
	DeleteObject(font);

}
/*
打字
*/
void glPrint(const char *pstr, GLfloat mColor[4], GLfloat x, GLfloat y)
{
	glPushAttrib(GL_LIST_BIT | GL_CURRENT_BIT | GL_ENABLE_BIT | GL_LIGHTING_BIT);//把当前的属性压栈
	glDisable(GL_DEPTH_TEST);
	glDisable(GL_LIGHTING);
	glDisable(GL_TEXTURE_2D);
	glColor4f(mColor[0], mColor[1], mColor[2], mColor[3]);
	glRasterPos2f(x, y);//输出位置
	glListBase(base - 0);		// 设置显示列表的基础值
	glCallLists(strlen(pstr), GL_UNSIGNED_BYTE, pstr);	// 调用显示列表绘制字符串
	glPopAttrib();//把之前的属性弹栈
}

画坐标轴

为什么要画坐标轴?因为屏幕上除了你的着色器结果都是漆黑一片,你怎么知道你的渲染效果是对的还是错的,精确不精确?画个坐标轴之后,你可以对比下你的渲染结果。无论是从顶点位置上,还是顶点颜色对比上都能看出来。参数就一个,坐标轴长度大小,比如1。干的事情有三个,画三条线,再写三个字。写的字和坐标轴错开一点距离。注意这里的坐标都是世界坐标,需要跟着投影变换走的。
代码如下:

/*
画坐标轴
*/
void drawAxis(float size) {
		glBegin(GL_LINES);
		glColor3f(1, 0, 0);		  // x 轴红色
		glVertex3f(0, 0, 0);
		glVertex3f(size, 0, 0);

		glColor3f(0, 1, 0);		  // y 轴绿色
		glVertex3f(0, 0, 0);
		glVertex3f(0, size, 0);

		glColor3f(0, 0, 1);		  // z 轴蓝色
		glVertex3f(0, 0, 0);
		glVertex3f(0, 0, size);
		glEnd();

		GLfloat xColor[4] = { 1.0,0.0,0.0,1.0 };
		glTranslatef(0.1 + size, 0, 0);
		glPrint("x", xColor, 0, 0);
		glTranslatef(-0.1 - size, 0, 0);

		GLfloat yColor[4] = { 0.0,1.0,0.0,1.0 };
		glTranslatef(0, 0.1 + size, 0);
		glPrint("y", yColor, 0, 0);
		glTranslatef(0, -0.1 - size, 0);

		GLfloat zColor[4] = { 0.0,0.0,1.0,1.0 };
		glTranslatef(0, 0, 0.1 + size);
		glPrint("z", zColor, 0, 0);
		glTranslatef(0, 0, -0.1 - size);
}

初始化函数

没什么说的,直接上代码:

/*
初始化
*/
void init()
{

	//加载顶点和片段着色器对象并链接到一个程序对象上
	initShader("VertexShader.vert", "FragmentShader.frag");
	//绑定并加载VAO,VBO
	initVBO();
	//初始化相机位置
	resetCamera();
	BuildFont();//初始化字体
	glClearColor(0.0, 0.0, 0.0, 0.0); //清屏黑色
}

渲染函数

也没什么说的,稍微讲一下如何绑定参数。这里用了uniform的投影变换参数,就是把透视矩阵和模型视图矩阵乘在一起传给着色器,因为矩阵乘法满足结合律。由于这两个矩阵不停变化,所以我决定每帧渲染的时候都绑定一次。你也可以只在修改的时候才绑定。当然不绑定是不行的,不绑定你的渲染结果一定不正确。你可以用单位阵作为初始矩阵绑定,那样的画你看到的四边形应该是屏幕一半大小在正中央,就跟其他教程的一样了。
另外,着色器程序会对传统管线有影响,所以每次用的时候现绑定,用完再关掉(统统绑定回默认值0)。有人跟我纠缠说glUseProgram(0) 会出意外,因为根据蓝宝书说的,绑定0意味着采用undefined着色器程序。我想说,那你可以用其他办法。比如重新链接、初始化、绑定等等一堆操作,或者切换到一个什么也不干的program,或者在着色器中加条件语句开关。。。。。我觉得吧,在这个例子中,上面这句话已经够用了,而且显卡确实也理解了这句话的意思,毫无意外地取消了所有绑定,采用传统管线继续渲染,这正是我们要的效果。

还有,为什么要把所有旋转平移数据清零?因为我们采用的是增量法,每次只旋转平移很小的尺度,而不计算总的变换量。这样做的好处是,你按一下‘r’ 键重置屏幕的时候,不会带着上次的旋转平移结果。这在相机标定工作中尤其管用,因为你需要用你的机器学习算法去学习投影矩阵参数(M的12个分量),用渲染结果比对真实图片,不希望受到手动变换的累计误差影响。

/*
	渲染函数
*/
void display()
{
	// 以相机坐标为参照平移
	glTranslatef(cameraTranslate.x * xDirection.x, cameraTranslate.x * xDirection.y, cameraTranslate.x * xDirection.z);
	glTranslatef(cameraTranslate.y * yDirection.x, cameraTranslate.y * yDirection.y, cameraTranslate.y * yDirection.z);
	glTranslatef(cameraTranslate.z * zDirection.x, cameraTranslate.z * zDirection.y, cameraTranslate.z * zDirection.z);
	//以物体自身坐标系为参考旋转
	glRotatef(angleRoll, 1, 0, 0);
	glRotatef(angleYaw, 0, 1, 0);
	glRotatef(anglePitch, 0, 0, 1);
	//重算相机位置
	getCameraFromModelView();
	//画完把参数回零
	cameraTranslate = glm::vec4(0, 0, 0, 1);
	angleYaw = 0;
	angleRoll = 0;
	anglePitch = 0;
	//清屏
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
	//使用VAO、VBO绘制
	//使用着色器程序
	glUseProgram(programHandle);//启用渲染程序
	glm::mat4 modelViewProjectionMatrix = perspectiveMatrix * modelViewMatrix;//模型投影矩阵 = 透视矩阵*模型视图矩阵
	//给着色器绑定投影矩阵参数
	GLuint loc = glGetUniformLocation(programHandle, "ModelViewProjectionMatrix");
	if (loc >= 0) glUniformMatrix4fv(loc, 1, GL_FALSE, &modelViewProjectionMatrix[0][0]);
	glBindVertexArray(vaoHandle);//给着色器绑定顶点
	glDrawArrays(GL_TRIANGLE_FAN, 0, 4);//开始画
	glBindVertexArray(0);//画完再取消顶点绑定,这样就可以画其他的东西了,比如坐标轴
	glUseProgram(0);//取消渲染程序使用,这样就可以使用opengl的传统管线进行绘制了,比如写字
	drawAxis(1.0);//画长度为1的坐标轴
	//把一些参数输出到屏幕
	GLfloat mColor[4] = { 0.0, 1.0, 0.2, 1.0 };  //字体颜色
	char msg[256];//字符串,最大256字节
	glPushMatrix();//把视图矩阵压栈
	glLoadIdentity();//初始化视图矩阵,这里是为了让字体不跟着模型走
	glTranslatef(0, 0, -1);//字要写在相机前方才看得见
	sprintf_s(msg, "Camera: (%.2f, %.2f, %.2f)", cameraPosition[0], cameraPosition[1], cameraPosition[2]);
	glPrint(msg, mColor, -0.45, 0.45);//输出相机位置到屏幕
	sprintf_s(msg, "Right Direction: (%.2f, %.2f, %.2f)", xDirection[0], xDirection[1], xDirection[2]);
	glPrint(msg, mColor, -0.45, 0.4);//输出相机x轴方向到屏幕
	sprintf_s(msg, "Up Direction: (%.2f, %.2f, %.2f)", yDirection[0], yDirection[1], yDirection[2]);
	glPrint(msg, mColor, -0.45, 0.35);//输出相机y轴方向到屏幕
	sprintf_s(msg, "Back Direction: (%.2f, %.2f, %.2f)", zDirection[0], zDirection[1], zDirection[2]);
	glPrint(msg, mColor, -0.45, 0.3);//输出相机z轴方向到屏幕
	glPopMatrix();//把视图矩阵弹栈
				 
	glutSwapBuffers();//把当前缓冲切换到前台显示
}

键盘响应函数

没什么说的,这里注册两个按键,r 用于重置视角,ESC 用于安全退出

/*
键盘事件回调函数
*/
void handleKeyboard(unsigned char key, int x, int y)
{
	switch (key)
	{
	case 'r':
	case 'R':
		resetCamera();
		glutPostRedisplay();
		break;
	case 27://ESC键用于退出使用着色器
		exit(0);
	}
}

鼠标操作

稍微讲一下鼠标操作。为了能够从不同角度观看渲染效果,我定义了鼠标左键、右键、中键的回调函数。左键和右键控制物体的旋转(XYZ三个轴),中键负责按照相机视角平移(XY方向),滚轮实现缩放(Z方向)。注意这里的缩放并不是改变投影矩阵,而只是z方向平移远离或靠近相机。由于我们的视角已定,模型矩阵行列式为1,透视矩阵为单位大小,所以不存在严格意义上的缩放。

/* 鼠标按键回调函数
每当有鼠标按下时候,记录下按键的位置和哪个键
*/
void handleMouseButtons(int button, int state, int x, int y) {
	
	if (state == GLUT_UP)//只记录按下,不记录抬起
	{
		pressedButton = -1;// 没有按键按下
	}
	else {
		pressedButton = button;
		if (button == 3 || button == 4) // 上滚轮 || 下滚轮 进行缩放,就是按照相机z坐标平移
		{
			if (button == 3)
			{
				cameraTranslate.z = 1.0 * TRANSLATEFACTOR;
			}
			else
			{
				cameraTranslate.z = -1.0 * TRANSLATEFACTOR;
			}
			glutPostRedisplay();
		}
		else
		{
			MouseY = y;		// invert y window coordinate to correspond with OpenGL
			MouseX = x;
		}
	}

}

/* 
鼠标移动回调函数
每当有鼠标移动时候,记录下移动后的新位置
*/
void handleMouseMotion(int x, int y) {
	//跟上次位置相减计算偏移量
	int dy = y - MouseY; 
	int dx = x - MouseX;

	switch (pressedButton) {
	case GLUT_LEFT_BUTTON: //左键,根据物体自身坐标系进行旋转
		angleRoll = ROTFACTOR * dy; //Roll
		angleYaw = ROTFACTOR * dx; //Yaw
		glutPostRedisplay();
		break;
	case GLUT_MIDDLE_BUTTON: //中键,根据相机视角进行平移
		cameraTranslate.x = TRANSLATEFACTOR * dx;
		cameraTranslate.y = TRANSLATEFACTOR * -dy;
		glutPostRedisplay();
		break;
	case GLUT_RIGHT_BUTTON: //右键,根据相机视角进行旋转,分为顺时针和逆时针
		double old_theta = atan2(MouseY - WND_HEIGHT/2.0, MouseX - WND_WIDTH / 2.0);
		double new_theta = atan2(y - WND_HEIGHT / 2.0, x - WND_WIDTH / 2.0);
		anglePitch = (old_theta - new_theta) / PI * 180;
		glutPostRedisplay();
		break;
	}
	
	MouseX = x;
	MouseY = y;
}

main 函数

不解释:

int main(int argc, char** argv)
{
	glutInit(&argc, argv);
	glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH);
	glutInitWindowSize(WND_WIDTH, WND_HEIGHT);
	glutInitWindowPosition(100, 100);
	glutCreateWindow("Hello GLSL");
	//初始化glew扩展库,一定要在opengl和窗口都初始化之后执行
	GLenum err = glewInit();
	if (GLEW_OK != err)
	{
		cout << "Error initializing GLEW: " << glewGetErrorString(err) << endl;
	}
	init();
	glutDisplayFunc(display);
	glutKeyboardFunc(handleKeyboard);//注册键盘回调函数
	glutMouseFunc(handleMouseButtons);//注册鼠标按键回调函数
	glutMotionFunc(handleMouseMotion);//注册鼠标移动回调函数
	glutMainLoop();
	glDeleteLists(base, 256);//删除字体
	return 0;
}

顶点着色器

文件名 VertexShader.vert

#version 440  
in vec4 VertexPosition;  
in vec4 VertexColor;
uniform mat4 ModelViewProjectionMatrix;
out vec4 Color;  
void main()  
{  
    Color = VertexColor;
    gl_Position = ModelViewProjectionMatrix * VertexPosition;
}  

片元着色器

文件名 FragmentShader.frag

#version 440     
in vec4 Color;
out vec4 FragColor;    
void main()  
{  
    FragColor = Color;  
} 

如何调试

现在来解决着色器出错的问题。回到开头提到的问题,我们一个一个来想办法解决

着色器没有编译成功

控制台输出里面有详细的错误报告。如果编译出错,你应该找找错误出在哪一行。本例中着色器相当简单,基本不会出错。唯一需要注意的就是矩阵和向量的乘法顺序,已定不能搞错。另外,矩阵和向量都是4维的,如果不足4维已定要补出一位置成1,否则结果就不对了。glm 0.9.9.5版本中初始化函数 glm::mat4()已经和openGL看齐,采用0矩阵而非单位矩阵初始化。而glm::mat4(1.0) 才是正确的初始化方法。

着色器没有加载,或者绑定错误,或者参数错误

具体分为3个情况分别调试:

  1. 如果是渲染程序没有加载,那你无论输入到着色器里面,都画不出来。所以简单点就是把片元着色器的输出色改为红色,看看是不是一屏幕红色。如果渲染程序没有解绑,那你的坐标轴就颜色不对了,看看还是不是红绿蓝。
  2. 如果着色器没有绑定,方法同上
  3. 如果着色器的参数没有传对,可以在着色器中加条件语句。比如在本例中,在顶点着色器稍微修改:
	Color = VertexColor;
	if(ModelViewProjectionMatrix[0][0] == 0) Color.r = 1.0;
    //gl_Position = ModelViewProjectionMatrix * VertexPosition;
	gl_Position = VertexPosition;

上面的语句把透视变换取消改为原封不动输出,所以你应该可以在屏幕中心看到一个一半大小的正方形。如果四个点都偏红,说明投影矩阵第一个数是0的情况发生了。这很可能是你绑定了错误的矩阵计算结果或者错误的变量名称。具体值是多少,你可以用小于或大于号折半逼近。这法子很土,但很有效。高手可以装上一些调试工具,比如英伟达的那些。

渲染错误、不在视野中或颜色不对

如果是由着色器引起的,方法同上,改改参数看效果。
如果是由参数引起的,比如你的点位置初始化不对,试着改改位置和颜色。需要注意的是,没有透视的情况下,着色器采用的是正交透视变化,也就是上面那个立方体视图。所以超出[-1,1]区间部分的内容并不会被渲染。有透视变换后,你可以在控制台输出每一个顶点变换后的相机坐标,看看是不是在视锥内(z值在剪裁面之间,x和y值缩放后落到-1,1区间内。缩放比例尺是z / focal) 。

渲染都正确,但是程序没有刷新

你可以把glutSwapBuffers() 加上判断条件,试试只调用一次,看看是不是第一帧渲染对了,后面把正确的结果洗掉了。这种情况下你就需要逐行调试,看看到底哪一行的变量是错误的。比如一个显而易见的错误,就是你的某个着色器变量不是全局变量而是局部变量,那么你下次绑定的时候就会出错。

实战演练

  1. 下面这幅图明显错了,片元和xy平面有了一定距离。通过平移发现片元的位移和轴并不一样多,会出现交错,并不是一定谁在前谁在后。坐标轴、文字都没错。如果不画坐标轴,都不一定能发现这个错误
  2. 更多测试后发现,旋转变换没有这个错误。把坐标轴大小从1改为0.5,效果更新如后面图所示
    OpenGL着色器透视变换实例-通过旋转平移调试着色器_第2张图片
    OpenGL着色器透视变换实例-通过旋转平移调试着色器_第3张图片
    3.在仔细观察可以看到,0.5长度的坐标轴和图片不对齐,图片偏小(事实上是坐标轴偏大)。
    原因分析:
    对相机来说,这叫视差,是由于两台相机成像参数不同造成的!我们明明只有一台相机,却出现了视差,说明相机参数计算出错了,传统管线和着色器效果不一样。调试后发现,视图矩阵计算无误,着色器中的值和外部一样正确,那只有一种可能,就是透视矩阵出错了。用于计算透视矩阵的几个参数都很简单,这几个值是常量,也不会出错。那么接下来怀疑,是不是OpenGL传统管线算错了!为什么?回头看看视锥设定部分,用到了焦距参数,而焦距的公式是:
    double focalLength = viewHeight/2.0/tan(fieldOfView/2.0); // 这是对的,本例用的公式
    double focalLength = viewHeight/2.0/atan(fieldOfView/2.0); // 这是错的,手误用的公式
    一个小小的手误,把正切写成了反正切,虽然值差不多,但是结果就是算错了。
    着色器的坐标透视矩阵用的是视角,而OpenGL自己的透视变化用的是焦距作为视锥参数,所以就出现了两台相机的问题。你会说,这是你OpenGL设置的错,不是着色器的错,着色器画对了啊。问题是你事先并不知道这些事。试想一下,你用OpenGL生成纹理图,生成阴影,写字画画,用的都是传统管线的一些功能,看上去都是对的。结果偏偏和着色器格格不入,你第一反应是着色器的错吧?再比如,下次反过来,你的相机标定程序生成了正确的焦距,而视角fov是用上面公式的逆函数算的,那OpenGL传统管线就算对了,而着色器就算错了。

总结

本文介绍的是一种使用OpenGL传统管线结合着色器的调试方法,适合像楼主这样的新手,不想花太多精力从头学起,想信手拈来就能写渲染的初学者。通过调整视角和渲染结果以及动态输出参数,来验证着色器的功能,了解OpenGL深层的成像原理。
本文的目的上为了方便大家学习着色器(方便楼主拿分下载资源)。楼主水平有限,文中有许多错误,欢迎批评指正。结尾给出相机标定和纹理映射的学习成果,与君共勉。
OpenGL着色器透视变换实例-通过旋转平移调试着色器_第4张图片

你可能感兴趣的:(OpenGL,相机标定)