先上结果图:
再上源代码:VS2015工程文件含库
https://download.csdn.net/download/xiongyuanxy/11126556
学习OpenGL的同学可能被着色器着实恶心到了,上手难,还不容易调试,各种莫名其妙的错误。屏幕一片漆黑,到底是什么愿意呢?有以下可能:
怎么解决呢?最好的办法就是通过跟传统OpenGL管线对比,看看到底哪里出错了。
网上其他大神给出了很多着色器的教学帖子,我在此抛砖引玉,给出一个简单的着色器样例。为了方便调试,我们把着色器的结果输出到屏幕。可以通过对比传统管线渲染结果来验证。画个坐标轴,输出相机位姿参数,并允许用户通过鼠标拖拽来旋转缩放平移。找出问题,看看到底是哪里出的错。当然,这里面用到的相机姿态估计,几何变换,打印字体,鼠标操作等等小功能也是很实用的功能。
为了尽量减少学习难度,我把代码整合到一个文件中,并根据功能按顺序逐步贴出来。你可以简单的把它们合并之后,就能编译运行。我的代码中自带注释,也可以通过我的描述了解每段代码的含义。
后面还会给出顶点着色器VertexShader.vert和FragmentShader.frag的代码。
先给出ShaderDemo.cpp文件的代码
最简单的OpenGL依赖库,包括
#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;
}
本样例中着色器包含两个类型,顶点着色器和片元着色器。初始化的时候需要给出他们的代码文件名。代码略长,但是这是摘抄自大神们的工作,无需了解含义。大神已经写得很详细了。
在完成的时候,我注释掉了大神的使用着色器程序的那行代码,因为我们为了调试需要动态加载着色器程序,不希望着色器上来就接管渲染管线。反复编译链接着色器并不是好的操作,因为这会大大降低程序效率。理论上讲,你只需要在程序一开始编译链接着色器,然后再程序关闭之前释放掉他们就好了。本例中并没有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
*/
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子矩阵是普通的投影变换,包括了旋转和缩放,不包含平移。平移是通过移动最下方的向量来表示的。你可以计算下上面这个矩阵对不对。听我来给你分析:
算完视图变换矩阵还不够,还要算透视矩阵。透视矩阵就是说把三维的相机坐标点映射到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;
}
不解释:
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个情况分别调试:
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() 加上判断条件,试试只调用一次,看看是不是第一帧渲染对了,后面把正确的结果洗掉了。这种情况下你就需要逐行调试,看看到底哪一行的变量是错误的。比如一个显而易见的错误,就是你的某个着色器变量不是全局变量而是局部变量,那么你下次绑定的时候就会出错。
本文介绍的是一种使用OpenGL传统管线结合着色器的调试方法,适合像楼主这样的新手,不想花太多精力从头学起,想信手拈来就能写渲染的初学者。通过调整视角和渲染结果以及动态输出参数,来验证着色器的功能,了解OpenGL深层的成像原理。
本文的目的上为了方便大家学习着色器(方便楼主拿分下载资源)。楼主水平有限,文中有许多错误,欢迎批评指正。结尾给出相机标定和纹理映射的学习成果,与君共勉。