首先我们要通过画一个简单的三角形来熟悉基本的开发步骤,以下是在 mac 搭建的 OpenGL 环境上开发的代码,我们逐一讲解。上才艺 OpenGL 图元应用 Demo。
通过下面的内容,我们可以学习到以下知识
- OpenGL 的 10 种基本图元
- 正投影和透视投影
- 8 种固定管线下的存储着色器
- 深入理解渲染流程
1. main 函数中初始化 GLUT 库和 glew API
我们在 OpenGL 环境上开发需要依赖系统的 GLUT 库和 libGLTools.a 中的glew,main 函数作为程序的入口,我们把初始化 GLUT 库和 glew API 的方法卸载 main 函数中,接下来会逐一解析每行代码的含义及作用。
int main (int argc, char *argv[]) {
gltSetWorkingDirectory(argv[0]);
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_STENCIL);
glutCreateWindow("GL_Point");
glutInitWindowSize(800, 600);
glutReshapeFunc(ChangeSize);
glutDisplayFunc(RenderScene);
GLenum err = glewInit();
if (err != GLEW_OK) {
return -1;
}
SetupRC();
glutMainLoop();
return 0;
}
- gltSetWorkingDirectory(argv[0]):这是设置 OpengGL 的工作空间,防止在 Windows 上报错
- glutInit(&argc, argv):该函数只是传输命令参数,并初始化 GLUT 库。
- glutInitDisplayMode(unsigned int mode):在创建窗口时指定的显示模式,这里我们指定了 4 种显示模式,即GLUT_DOUBLE、GLUT_RGBA、GLUT_DEPTH、GLUT_STENCIL,分别指双缓冲区、颜色空间、深度测试、模板缓冲区,
- GLUT_DOUBLE:与之对应的是 GLUT_SINGLE(单缓冲),单缓冲指直接在屏幕上绘制,将导致渲染效率变差,性能稍差的机器会出现卡顿,而有了双缓冲机制,我们可以边读取一个缓冲区的内容,边在另外一个缓冲区执行渲染操作,最后通过交换缓冲区即可渲染图形到屏幕图片如何从文件渲染到屏幕。
- GLUT_RGBA:颜色缓冲区,另外还有GLUT_RGB 和 GLUT_INDEX模式,默认是 GLUT_RGBA,这个不必过多纠结,GLUT_RGBA 比 RGB 多一个透明度而已。
- GLUT_DEPTH:深度缓冲区,和颜色缓冲区一一对应,深度缓冲区存储图形的 z 值,z 值为图形的像素点到观察者的直线距离,在开启深度测试后,若目标 z 值小于深度缓冲区中的 z 值,则渲染对应像素点的颜色,否则该像素点将被丢弃。
- glutInitWindowSize(int width, int height):窗口大小
- glutCreateWindow(const char *title):创建窗口,并给窗口一个名字
- glutReshapeFunc(ChangeSize):重塑函数,在窗口发生改变时触发 ChangeSize 函数。
- glutDisplayFunc(RenderScene):渲染函数,在渲染或重塑时,会触发 RenderScene 函数。
- glewInit():初始化一个GLEW库。
- SetupRC():这是我们自己定义的函数,用于设置渲染环境,主要设置背景色、初始化着色器、初始化渲染工具类(GLBatch)等。
- glutMainLoop():OpenGL 内部运行一个本地消息循环,用于拦截适当的消息,然后调用我们注册的函数,比如上面的 glutReshapeFunc 和 glutDisplayFunc。
显示模式有很多种,我们可以根据需要自行设置,如图所示
2. 设置渲染环境
上面的 SetupRC 函数的作用是设置渲染环境,具体代码如下
void SetupRC()
{
//设置背景色
glClearColor(0.98f, 0.40f, 0.7f, 1);
//初始化一个着色器管理器。
//shaderManager 是定义的 GLShaderManager 类型的全局变量
shaderManager.InitializeStockShaders();
// 几何变换管道 GLGeometryTransform transformPipeline,
// 用来存储模型矩阵栈和透视投影矩阵栈
transformPipeline.SetMatrixStacks(modelViewMatrixStack, projectionMatrixStack);
// 观察者坐标系拉高 15 的距离
cameraFrame.MoveForward(-15.f);
// 三行分别表示物体坐标系的 x、y、z 坐标,之后会变成世界坐标、观察者坐标、裁剪坐标、标准化坐标、屏幕坐标
GLfloat vVerts[] = {
3,3,0,
0,3,0,
3,0,0
};
// GLBatch 类型变量,是简单的批次容器类,可帮助绘制图形
// 绘制由三条线组成的三角形环
lineLoopBatch.Begin(GL_LINE_LOOP, 3);
lineLoopBatch.CopyVertexData3f(vVerts);
lineLoopBatch.End();
}
首先初始化着色器,因为我们是在固定管线下做操作,所以我们需要初始化一下着色器管理器,后续在渲染时设置使用哪种着色器实现渲染操作。
shaderManager.InitializeStockShaders();
这里要说明一下坐标系
- 笛卡尔坐标系:从观察者的角度来看,x轴和y轴的正方向分别指向右方和上方。z轴的正方向从原点指向使用者,而z轴的负方向则从观察者指向屏幕内部。
当我们利用 OpenGL 进行 3D 绘制时,就会使用笛卡尔坐标系。如果不进行任何变换,那么使用的坐标系将与刚刚描述的笛卡尔坐标系相同。
几何变换管道 GLGeometryTransform
设置几何变换管道的目的是方便管理和计算模型矩阵栈和投影矩阵栈,因为transformPipeline
有一个GetModelViewProjectionMatrix
函数,可以获得经过模型变换(ModelView)和透视变换(Projection)的矩阵,否则只能自己计算矩阵相乘了。这里模型变换和透视变换可以结合上一篇说的物体坐标到屏幕坐标的流程去思考。
transformPipeline.SetMatrixStacks(modelViewMatrixStack, projectionMatrixStack);
观察者坐标
设置观察者坐标系,我们将观察者坐标系建立在物体坐标系前,在最后执行渲染操作时,还需要进行视变换,即用模型矩阵
和摄像机矩阵
相乘即可,后面会说具体实现。
GLFrame cameraFrame;
cameraFrame.MoveForward(-15.f);
设置顶点坐标,我们的顶点坐标是基于物体坐标系的,三行分别表示 x、y、z 坐标,物体坐标系之后会转换成世界坐标、观察者坐标、裁剪坐标、标准化坐标,再到最终的屏幕坐标。
GLfloat vVerts[] = {
3,3,0,
0,3,0,
3,0,0
};
图元
图元是 OpenGL 绘制的基本单元,有了顶点坐标,我们便开始设置图元,拷贝顶点数据到批次容器类 GLBatch,这里 Begin 函数的参数GL_LINE_LOOP
是基本图元类型,后面的数字表示图元的个数。CopyVertexData3f
表示装配顶点数据。调用End
函数告知 Batch 数据添加完毕。
GLBatch lineLoopBatch;
lineLoopBatch.Begin(GL_LINE_LOOP, 3);
lineLoopBatch.CopyVertexData3f(vVerts);
lineLoopBatch.End();
下面是基本的图元类型,一般使用前 7 种,OpenGL 最受欢迎的是三角形。
对于很多表⾯或者形状⽽⾔,我们会需要绘制⼏个相连的三⻆形,这时我们可以使⽤GL_TRIANGLE_STRIP
图元绘制⼀串相连
三⻆形,从⽽节省⼤量的时间。
⽤前3个顶点指定第1个三⻆形之后,对于接下来的每⼀个三⻆形,只需再指定1个顶点。需要绘制⼤量的三⻆形时,采⽤这种⽅法可以节省⼤量的程序代码和数据存储空间。还可以提高运算性能和节省带宽。更少的顶点意味着数据从内存传输到图形卡的速度更快,并且顶点着⾊器需要处理的次数也更少了。
3. 重塑函数 ChangeSize
在设置完渲染方式,OpenGL 开始将开始执行重塑函数,这里我们设置的函数是 ChangeSize,每次渲染都会执行 ChangeSize。
void ChangeSize(int w, int h) {
glViewport(0, 0, w, h);
viewFrustum.SetPerspective(35, w / h * 1.0, 1.f, 100.f);
projectionMatrixStack.LoadMatrix(viewFrustum.GetProjectionMatrix());
modelViewMatrixStack.LoadIdentity();
}
glViewport
是设置视口,一般视口大小和窗口相同,OpenGL 渲染的图形只能显示在视口内。
在这个函数内重新初始化模型矩阵栈modelViewMatrixStack
和投影矩阵栈projectionMatrixStack
。
因为每次重绘,我们都要根据宽高比设置透视投影,所以要在此函数内重新初始化模型矩阵栈和投影矩阵栈,它分为正投影和透视投影两种。
正投影
正投影和照镜子一样,物体有多大,投影就有多大。一般在渲染 2D 平面图形时,选择设置正投影,进而获得投影矩阵。
GLFrustum::SetOrthographic(GLfloat xMin,
GLfloat xMax,
GLfloat yMin,
GLfloat yMax,
GLfloat zMin,
GLfloat zMax)
GLFrustum 默认是正投影,
透视投影
透视投影呈现远小近大的效果,一般渲染 3D 图形时,选择设置透视投影,进而获得投影矩阵。
GLFrustum::SetPerspective(float fFov , float fAspect ,float fNear ,float fFar)
- fFov:垂直方向上的视场角度
- fAspect:窗口的宽、高比(width / height)
- fNear:视角到靠近裁剪面的距离
- fFar:视角到远离裁剪面的距离
如图所示
联想上一篇提到的物理坐标到屏幕坐标的渲染流程,这一步就是投影变换,从观察者坐标变成裁剪坐标。
void SetPerspective(float fFov, float fAspect, float fNear, float fFar)
4. 执行渲染
在做完上述准备工作(设置渲染环境、重塑函数)后,最后一步执行渲染操作,我们需要告诉固定管线用哪种着色器渲染,
void RenderScene() {
// 清除缓冲区,防止数据互相影响
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
// 将之前装载的模型矩阵栈压栈
modelViewMatrixStack.PushMatrix();
// 计算变换视角后的矩阵
M3DMatrix44f camera;
cameraFrame.GetCameraMatrix(camera);
modelViewMatrixStack.MultMatrix(camera);
shaderManager.UseStockShader(GLT_SHADER_FLAT, transformPipeline.GetModelViewProjectionMatrix(), vBlack);
// 设置线的宽度
glLineWidth(2.f);
lineLoopBatch.Draw();
// 因为 OpenGL 是状态机,它会记录设置过的参数,所以我们设置完还得改回默认
glLineWidth(1.f);
// 渲染完毕,模型矩阵出栈,等待下次装载矩阵
modelViewMatrixStack.PopMatrix();
// 交换缓冲区
glutSwapBuffers();
}
清除缓冲区
OpenGL 基于庞大的状态机,在渲染完上一个图形后,各缓冲区中可能存有上次数据,所以一般会先执行清除缓冲区操作
glClear (GLbitfield mask);
GLbitfield
是unsigned int
类型的,本例中我们清空颜色缓冲区、深度缓冲区、和模板缓冲区。
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
矩阵变换
我们在SetupRC
函数中设置了物体坐标,结合上篇所学,物体坐标需要经过模型变换、视变换、投影变换、透视除法、视口变换才能称为最终显示在屏幕上的屏幕坐标,我们在之前拉高了视角cameraFrame.MoveForward(-15)
和设置了透视投影setPerspective
,所以需要进行视变换和投影变换。
// 将之前装载的模型矩阵栈压栈
modelViewMatrixStack.PushMatrix();
// 计算变换视角后的矩阵
M3DMatrix44f camera;
cameraFrame.GetCameraMatrix(camera);
modelViewMatrixStack.MultMatrix(camera);
首先是模型矩阵的压栈操作PushMatrix
,这里压栈的是在 ChangeSize
中初始化的单元矩阵,我们看一下PushMatrix
的源码就明白了
inline void PushMatrix(void) {
// stackPointer默认是 0,m3dCopyMatrix44是将后面的参数复制给前面的参数
if(stackPointer < stackDepth) {
stackPointer++;
m3dCopyMatrix44(pStack[stackPointer], pStack[stackPointer-1]);
} else lastError = GLT_STACK_OVERFLOW;
}
然后获取观察者坐标矩阵
// 计算变换视角后的矩阵
M3DMatrix44f camera;
cameraFrame.GetCameraMatrix(camera);
最后模型矩阵和观察者矩阵相乘,得到经过视变换的矩阵。
modelViewMatrixStack.MultMatrix(camera);
至此,完成了模型变换和视变换,因为我们还设置了透视投影,所以我们需要进行投影变换,这里就看到我们用几何变换管道的好处了,我们直接获取经过投影变换的矩阵就好了。
transformPipeline.GetModelViewProjectionMatrix()
设置着色器
顶点数据准备好了图元装配完成),现在需要设置使用哪种着色器执行渲染操作。
shaderManager.UseStockShader(GLT_SHADER_FLAT, transformPipeline.GetModelViewProjectionMatrix(), vBlack);
案例中使用的是平面着色器,下面来看一下固定管线下的存储着色器都有哪些。
单元着色器
单元着色器是只能渲染一个纯色的图形。图形的位置也只是默认位置(笛卡尔坐标系[-1.0, 1.0])。
GLShaderManager::UseStockShader(GLT_
SHADER_IDENTITY, GLfoat vColor[4]);
- 参数 1:GLT_SHADER_IDENTITY,表示单元着色器
- 参数 2:vColor[4],颜色值
绘制默认OpenGL 坐标系(-1,1)下的图形,图形所有⽚段都会以⼀种颜⾊填充。
平面着色器
平面着色器的第 2 个参数允许传入变换的矩阵,如旋转、平移、缩放。
GLShaderManager::UseStockShader(GLT_SHADER_FLAT, GLfoat mvp[16], GLfloat vColor[4]);
- 参数 1:GLT_SHADER_FLAT,表示平面着色器
- 参数 2:mvp[16],表示一个允许变化的 4 * 4 矩阵
- 参数 3:vColor[4],颜色值
在绘制图形时, 可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影。
上色着色器
GLShaderManager::UserStockShader(GLT_SHADER_SHADED,GLfloat mvp[16]);
- 参数1: 存储着⾊器种类-上⾊着⾊器
- 参数2: 允许变化的4*4矩阵
在绘制图形时,可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影,不同于平面着色器的是,颜⾊会平滑地插⼊到顶点之间,称为平滑色。
默认光源着色器
GLShaderManager::UserStockShader(
GLT_SHADER_DEFAULT_LIGHT,
GLfloat mvMatrix[16],
GLfloat pMatrix[16],
GLfloat vColor[4]
);
参数1: 存储着⾊器种类-默认光源着⾊器
参数2: 模型4*4矩阵
参数3: 投影4*4矩阵
参数4: 颜⾊值
在绘制图形时,可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影,这种着⾊器会使绘制的图形产⽣阴影和光照的效果。
点光源着色器
GLShaderManager::UserStockShader(
GLT_SHADER_POINT_LIGHT_DIEF,
GLfloat mvMatrix[16],
GLfloat pMatrix[16],
GLfloat vLightPos[3],
GLfloat vColor[4]
);
参数1: 存储着⾊器种类-点光源着⾊器
参数2: 模型4*4矩阵
参数3: 投影4*4矩阵
参数4: 点光源的位置
参数5: 漫反射颜⾊值
在绘制图形时,可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影,和默认光源着⾊器一样会使绘制的图形产⽣阴影和光照的效果,区别是光源位置可以自行设定。
纹理替换矩阵着色器
GLShaderManager::UserStockShader(
GLT_SHADER_TEXTURE_REPLACE,
GLfloat mvMatrix[16],
GLint nTextureUnit
);
参数1: 存储着⾊器种类-纹理替换矩阵着⾊器
参数2: 模型4*4矩阵
参数3: 纹理单元
在绘制图形时,可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影。使⽤纹理单元来进⾏颜⾊填充,其中每个像素点的颜⾊是从纹理中获取的。
纹理调整着色器
GLShaderManager::UserStockShader(
GLT_SHADER_TEXTURE_MODULATE,
GLfloat mvMatrix[16],
GLfloat vColor[4],
GLint nTextureUnit
);
参数1: 存储着⾊器种类-纹理调整着⾊器
参数2: 模型4*4矩阵
参数3: 颜⾊值
参数4: 纹理单元
在绘制图形时,可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影,着⾊器将⼀个基本⾊乘以⼀个取⾃纹理单元 nTextureUnit 的纹理,将颜⾊与纹理进⾏颜⾊混合后才填充到⽚段中。
纹理光源着色器
GLShaderManager::UserStockShader(
GLT_SHADER_TEXTURE_POINT_LIGHT_DIEF,
GLfloat mvMatrix[16],
GLfloat pMatrix[16],
GLfloat vLightPos[3],
GLfloat vBaseColor[4],
GLint nTextureUnit
);
参数1: 存储着⾊器种类-纹理光源着⾊器
参数2: 模型4*4矩阵
参数3: 投影4*4矩阵
参数4: 点光源位置
参数5: 颜⾊值(⼏何图形的基本⾊)
参数6: 纹理单元
在绘制图形时,可以接受矩阵变换(模型/投影变化),即可以设置观察者坐标和设置透视投影。着⾊器将⼀个纹理通过漫反射照明计算进⾏调整(相乘)。
以上就是固定管线中的存储着色器,建议从按顺序从上往下看,每个着色器都是比之前的着色器多点功能的。
Batch 开始渲染
最后一步,用批次容器类执行渲染操作
glLineWidth(2.f);
lineLoopBatch.Draw();
glLineWidth(1.f);
批次容器类中存储了顶点数据,在设置了着色器后,则开始渲染。
模型矩阵栈出栈
我们之前将模型矩阵入栈,在渲染完成后要执行出栈操作。
modelViewMatrixStack.PopMatrix();
交换缓冲区
我们之前看过图片如何从文件渲染到屏幕的过程,了解到,渲染操作是在离屏缓冲区执行的,在收到垂直讯号后会交换缓冲区,使得视频控制器指针指向已经渲染好的缓冲区,进而显示到屏幕上。
由此我们也可以得知,我们在使用 OpenGL 时,是可以控制 GPU 的。
最终我们的 Demo 效果如下
别看废了半天劲只画出个这玩意儿,其实道理都是相同的,如果想画一个可旋转的 3D 图形,只需改动几行代码,所以熟悉整个渲染流程才是关键,下期再见,画一个 3D 可旋转的图形。