二维是三维的一个特例,要学习OpenGL三维编程,我们先从简单的二维学起。这一篇将主要介绍如何<mark>使用OpenGL绘制二维图形</mark>,然后再在这些图形基础上<mark>添加一些简单的动画与交互</mark>

二维编程

这是入门OpenGL的一个经典例子。假设我们要在灰色的背景上绘制一个白色的正方形,并在屏幕上显示,我们应该怎么做呢?

我们先来看看要绘制怎样一个图形,OpenGL是怎么做的。

OpenGL的工作流程

在上一篇里面我们知道,OpenGL进行图形的绘制,而不管窗口的显示;窗口的显示我们选用glut来实现。所以OpenGL的绘制和窗口显示应该是分开的:

OpenGL要绘制这样的图形,是由前景和背景叠加的。很显然,应该先绘制背景,再绘制前景:

要将OpenGL绘制的图形在窗口中显示出来,需要进行投影变换视口变换 。可以这样想,这个正方形是在一张无限大的纸上绘制的,投影变换就是从这张纸上裁剪出我们需要显示的部分;而从截取的画面到视口的变换,就是视口变换。其中视口就是窗口上显示对应画面的部分——我们可以设置视口在窗口的哪部分显示,显示范围有多大等。

至此可以给出OpenGL绘制流水线,这个过程将在OpenGL三维编程中详细讲解。(现在不需要详细理解,看看就行)


编写程序

了解了OpenGL的执行流程,我们来看看如何编写我们的程序。

窗口的配置和创建

对于一个OpenGL程序,窗口显示是必须的(不然绘制了拿来干嘛)。而C语言主函数是程序的入口,所以我们可以将窗口的配置和创建放在主函数中。此前还要进行glut的初始化,这是必须的步骤。

OpenGL的初始化

OpenGL是一个状态机,开机时我们可以设置一些启动时默认的配置,如是否打开光照等。这里我们需要一个灰色的背景,所以设置清屏的颜色为灰色:

思考为什么OpenGL初始化放在一个init函数里面最好?

因为当程序复杂起来的时候,OpenGL需要初始化的有很多。为了保证主函数的简洁性和程序的可读性,我们最好新建一个函数再调用。

注册回调函数

回调函数是显示、创建动画与交互的关键。这里我们只关注显示回调函数 glutDisplayFunc();。我们先来看一下这个函数的信息:

从编辑器的提示可以知道,glutDisplay()这个函数的参数应该是一个无参的返回类型为空的函数指针,所以我们创建这样一个函数,函数声明为:void display();,然后将函数名作为参数传给 glutDisplay()即可。

dispaly()这个函数的定义中,我们需要实现的就是图形的绘制。

进入事件循环

回调函数一般有很多,包括鼠标、键盘的处理等。所谓事件循环,就是一直监听用户事件的无限循环。每当用户产生了某个事件,如鼠标点击、键盘输入等,就会调用相应的回调函数进行处理。

需要注意的是,程序一旦执行到 glutMainLoop()后就会进入无限循环,其后面的代码都不会执行。所以 glutMainLoop()一般就是主函数的最后一行,return 0是多余的,不需要写。

图形的绘制

我们最后来看一下绘制的过程:

第一行就是清空当前颜色缓存,然后覆盖为之前设定的灰色,这就是背景的绘制;glBegin()glEnd()之间就是正方形的绘制,这在下面的几何图元中将详细讲解,这是前景的绘制;最后 glFlush()提交上面绘制的图形。


上面例子的程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <glut.h>

void display(){
glClear(GL_COLOR_BUFFER_BIT);
glBegin(GL_POLYGON);
glVertex2f(-0.5,-0.5);
glVertex2f(-0.5,0.5);
glVertex2f(0.5,0.5);
glVertex2f(0.5,-0.5);
glEnd();
glFlush();
}

void init(){
glClearColor(0.5,0.5,0.5,1.0);
}

int main(int argc,char* argv[]){
glutInit(&argc,argv);
glutInitWindowPosition(300,100);
glutInitWindowSize(500,500);
glutCreateWindow("squre");

init();
glutDisplayFunc(display);
glutMainLoop();
}

这个过程用流程图表示就是这样的:

几何图元

上面的程序中我们用四个顶点绘制了一个正方形,这就是一个图元。OpenGL提供了多种图元供我们选择,接下来我们来看看OpenGL中的图元,进一步了解怎么绘制我们需要的图形。

图元种类

毫无疑问,要组成一个画面,我们需要的图元有点、线、面。

OpenGL程序中,我们需要在绘制前指定图元种类,即在 glBegin()中指出。根据指定的图元种类,OpenGL可以知道如何处理我们给出的顶点信息,如是否将顶点连线、或者围成一个封闭的多边形等。

我们可以使用 GL_POINTS来指定绘制的图形为一系列离散的点。

线

线的样式常用的主要有以下三种:

第一种是将顶点分为两两一组,每两个点连成一条直线段;第二种是将顶点依次连接;第三种在第二种的基础上还将顶点首尾相连

所以用上面三种样式来绘制的“正方形”依次如下:

OpenGL提供的面的样式就更多了:可以是将所有顶点围成的一个面;可以是三角面片;也可以是四边形面片。实际应用中,由于三角形的稳定性即三点恰好确定一个面,我们更常用的是三角面片

最开始绘制的白色正方形就是使用的 GL_POLYGON

图元属性

不同的图元有一些共用的属性,也有一些独特的属性,下面列出几个常用的:

函数 属性描述
glColor3f() 图元的颜色
glPointSize() 顶点大小
glLineWidth() 线的宽度
glShaderModel() 面的着色模式

上面三个都挺好理解,我们来看看最后一个 glShaderModel()

这个函数可选属性有 GL_SMOOTHGL_FLAT,我们通过程序来对比一下二者的区别:

从上图我们可以很直观得看出: GL_SMOOTH着色是渐变的,它使用颜色插值来计算中间的颜色GL_FLAT着色是单一的,它将第一个遇到的颜色作为平面的颜色


练习

编写程序画出下面一个图形:

我们一起来分析一下这个图形的构成。首先背景和之前一样都是灰色的;中间的圆可以分为两部分,一部分是白色的圆框,另一部分是里面渐变的圆

那么圆又该如何绘制呢?

最基本的想法是用正多边形来逼近圆,这可以作为边框的画法,但不能实现内部的渐变。要实现渐变,我们可以使用三角面片和颜色插值:三角形的一个顶点始终在圆心,另外两个顶点就在圆上——使用很多个三角面片即可组成圆。

我们首先定义一些要用的常量:

1
2
#define PI 3.1415926535f
#define RADIUS 0.8f//圆的半径

然后是圆形边框的绘制:

1
2
3
4
5
6
7
8
9
10
void border(){
glColor3f(1,1,1);
glLineWidth(6.5);
glBegin(GL_LINE_LOOP);
for(int i=0;i<100;i++){
GLfloat angle=(float)i/100*2*PI;
glVertex2f(RADIUS*cos(angle),RADIUS*sin(angle));
}
glEnd();
}

再然后是内部填充的绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void fill(){
glBegin(GL_TRIANGLE_FAN);
GLfloat temp=0;
for(int i=1;i<=100;i++){
GLfloat angle=(float)i/100*2*PI;
glColor3f(0.3,0.3,0.3);
glVertex2f(0,0);
glColor3f(0.8,0.8,0.8);
glVertex2f(RADIUS*cos(angle),RADIUS*sin(angle));
glVertex2f(RADIUS*cos(temp),RADIUS*sin(temp));
temp=angle;
}
glEnd();
}

最后显示函数调用两个函数即可:(注意调用的顺序)

1
2
3
4
5
6
void display(){
glClear(GL_COLOR_BUFFER_BIT);
fill();
border();
glFlush();
}

程序的源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <glut.h>
#include <cmath>

#define PI 3.1415926535f
#define RADIUS 0.8f//圆的半径

void border(){
glColor3f(1,1,1);
glLineWidth(6.5);
glBegin(GL_LINE_LOOP);
for(int i=0;i<100;i++){
GLfloat angle=(float)i/100*2*PI;
glVertex2f(RADIUS*cos(angle),RADIUS*sin(angle));
}
glEnd();
}

void fill(){
glBegin(GL_TRIANGLE_FAN);
GLfloat temp=0;
for(int i=1;i<=100;i++){
GLfloat angle=(float)i/100*2*PI;
glColor3f(0.3,0.3,0.3);
glVertex2f(0,0);
glColor3f(0.8,0.8,0.8);
glVertex2f(RADIUS*cos(angle),RADIUS*sin(angle));
glVertex2f(RADIUS*cos(temp),RADIUS*sin(temp));
temp=angle;
}
glEnd();
}

void display(){
glClear(GL_COLOR_BUFFER_BIT);
fill();
border();
glFlush();
}

void init(){
glClearColor(0.5,0.5,0.5,1.0);
}

int main(int argc,char* argv[]){
glutInit(&argc,argv);
glutInitWindowPosition(300,100);
glutInitWindowSize(500,500);
glutCreateWindow("circle");

init();
glutDisplayFunc(display);
glutMainLoop();
}

动画与交互

概述

用户与OpenGL交互的机制,就是:①用户触发一系列的事件后,②OpenGL对这些事件做出响应,从而③修改OpenGL状态机内部的各种状态,④最终使得渲染的图像发生改变的过程。

用户的事件,包括键盘的输入、鼠标的按下和弹起、窗口尺寸的改变等。但是,这些事件都和操作系统相关,OpenGL本身是不处理的。我们可以借助glut实用工具集,来响应这些事件,处理后传递给OpenGL。

所以动画与交互的实现,与glut是紧密相关的。可以说,下面涉及到的基本上都是glut库的函数。

使用双缓存

OpenGL中的缓存(frame buffer)有两个职责:
  1. 作为渲染流水线的输出;
  2. 作为显示器(硬件)的输入。

OpenGL默认使用单缓存,这时一个缓存同时行使上面两个职责。对于静态场景的绘制,使用单缓存是没问题的;但是当我们绘制动画时如果使用单缓存,屏幕就可能会产生闪烁——这时我们需要使用双缓存

首先在程序中开启双缓存,这步可以放在初始化glut那里:

然后提交绘制的图形时,使用 glutSwapBuffers(),而不用 glFlush()

所以为什么提交绘制的图形叫做swap?这就和双缓存的实现密切相关了。

当我们使用双缓存时,输入缓存和输出缓存是分开的。输出缓存又叫做前台缓存,屏幕上展现的就是前台缓存的内容;输出缓存又叫做后台缓存,OpenGL的绘制就在这个缓存中。调用 glutSwapBuffers(),代表我们绘制完成,此时后台缓存变为前台缓存用于输出我们刚才绘制的内容;前台缓存变为后台缓存用于绘制图像——这就是swap 的含义。通俗地讲,就是改变上图指针的指向。

glut的回调函数

窗口大小回调函数

这部分涉及到投影变换和视口变换,所以我尽可能讲得详细些。

当我们拖动窗口、改变窗口尺寸时,可以发现,图像发生了变形:

产生形变的原因就是视口变换时,画面映射到窗口的过程中图像被拉伸了(因为要填满窗口)。

这时我们就要使用 glutReshapeFunc(),监听窗口的宽和高。

根据编辑器的提示可以知道,这个函数的参数是一个形参为整型的窗口宽高,返回类型为空的函数指针。

当窗口的大小发生改变时,glutReshapeFunc()就会将窗口宽高作为参数传递给里面的函数,然后调用里面的 reshape()函数。我们只需要在 reshape()里面对投影变换和视口变换进行设置即可。

分析图像拉伸的原因,就是画面和窗口的比例不一样。所以我们截取画面时,保持图像在视口中心的同时,使画面的比例和窗口一样就行了。窗口比例分为下面两种情况:

情况一 width>height,截取画面时我们保持高度不变,宽度乘以比例:

1
gluOrtho2D(-1.0*width/height,1.0*width/height,-1.0,1.0);

情况二 width<height,截取画面时我们保持宽度不变,高度度乘以比例:

1
gluOrtho2D(-1.0,1.0,-1.0*height/width,1.0*height/width);

用于这个过程在投影变换中完成,所以需要设置矩阵为投影矩阵,同时将矩阵单位化:

1
2
glMatrixMode(GL_PROJECTION);
glLoadIdentity();

由于这个回调函数在回调函数中第一个执行,所以可以把视口变换也放在这函数中:(默认视口填满窗口)

1
glViewport(0,0,width,height);

所以 reshape()函数的代码如下:

1
2
3
4
5
6
7
8
9
10
void reshape(int width, int height){
glViewport(0,0,width,height);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
if(width<height)
gluOrtho2D(-1.0,1.0,-1.0*height/width,1.0*height/width);
else gluOrtho2D(-1.0*width/height,1.0*width/height,-1.0,1.0);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
}
我们最后再来一起总结上面的代码:
  1. glViewport()设置窗口中的(矩形)视口范围。前两个参数是视口左下角的点坐标,后两个参数是视口右上角的点坐标。这里视口填满窗口。
  2. glMatrixMode(GL_PROJECTION)设置当前矩阵为投影矩阵,表示下面是投影变换。
  3. glLoadIdentity()将该矩阵初始化为单位矩阵,防止之前矩阵的影响。
  4. if else语句,分情况地对投影进行调整。
  5. glMatrixMode(GL_MODELVIEW)将矩阵切换为模型—视图矩阵,表示视口变换结束。

键盘回调函数

处理键盘输入的回调函数主要是 glutKeyboardFunc(),编辑器的提示如下:

我们只需要关注第一个参数 key即可。这个函数主要接收键盘上的字母按键,并将该按键作为字符传给里面的函数。

比如在想要实现按下q或Q键退出,我们只需要这样编写 keyboard()函数即可:

1
2
3
void keyboard(unsigned char key, int x, int y){
if(key=='q'||key=='Q') exit(0);
}

键盘上除了字母按键,还有其他按键,所以OpenGL还提供了其它按键处理函数:

函数 处理按键
glutSpecialFunc() 处理上下左右键、Fn键、page键、home、end键等
glutGetModifiers() 处理shift、ctrl、alt键等

这些函数都很简单,读者可以自己研究。

鼠标回调函数

鼠标回调函数主要是 glutMouseFunc(),编辑器的提示如下:

我们主要需要前两个参数:button的值可以是 GLUT_LEFT_BUTTONGLUT_MIDDLE_BUTTONGLUT_RIGHT_BUTTON,分别是鼠标左中右键;state的值可以是 GLUT_UPGLUT_DOWN,分别是按下和弹起。

此外OpenGL还提供了其他鼠标相关的回调函数:

函数 描述
glutMotionFunc 鼠标按下时移动
glutPassiveMotionFunc 鼠标没按下时移动
glutEntryFunc 鼠标进入或离开窗口

这些函数同样比较简单,读者也可以自己研究研究。

空闲回调函数

glutIdelFunc,即空闲回调函数,是当用户没有产生事件时就会一直自动执行 的函数,是实现动画 的关键。我们直接来看下面一个例子:

如果要在之前的基础上再添加一个顺时针旋转的指针,我们应该怎么做呢?

我们一起来分析一下。实现指针的创建很简单,直接绘制一条直线段即可——其中一点固定为圆心,另一点绕圆旋转。

所以我们为指针旋转的角度创建一个全局变量,初始时为 PI/2

1
2
3
4
#define PI 3.1415926535f
#define RADIUS 0.8f//圆的半径
#define LENTH 0.7f//指针长度
GLfloat theta=PI/2;

绘制指针的函数如下,另一点与角度相关:

1
2
3
4
5
6
void hand(){
glBegin(GL_LINES);
glVertex2f(0,0);
glVertex2f(cos(theta)*LENTH,sin(theta)*LENTH);
glEnd();
}

要实现指针旋转,我们只需要在闲时回调函数里面让角度不断减小即可顺时针旋转。

1
2
3
4
5
void runtime(){
theta-=0.0005;
if(theta<=-2*PI) theta+=2*PI;
glutPostRedisplay();
}

这里利用周期性限制 theta大小是为了防止一直减下去导致数据溢出(虽然那得等很久)。

源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <glut.h>
#include <cmath>

#define PI 3.1415926535f
#define RADIUS 0.8f//圆的半径
#define LENTH 0.7f//指针长度
GLfloat theta=PI/2;

void reshape(int width, int height){
glViewport(0,0,width,height);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
if(width<height)
gluOrtho2D(-1.0,1.0,-1.0*height/width,1.0*height/width);
else gluOrtho2D(-1.0*width/height,1.0*width/height,-1.0,1.0);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
}

void border(){
glColor3f(1,1,1);
glLineWidth(6.5);
glBegin(GL_LINE_LOOP);
for(int i=0;i<100;i++){
GLfloat angle=(float)i/100*2*PI;
glVertex2f(RADIUS*cos(angle),RADIUS*sin(angle));
}
glEnd();
}

void fill(){
glBegin(GL_TRIANGLE_FAN);
GLfloat temp=0;
for(int i=1;i<=100;i++){
GLfloat angle=(float)i/100*2*PI;
glColor3f(0.3,0.3,0.3);
glVertex2f(0,0);
glColor3f(0.8,0.8,0.8);
glVertex2f(RADIUS*cos(angle),RADIUS*sin(angle));
glVertex2f(RADIUS*cos(temp),RADIUS*sin(temp));
temp=angle;
}
glEnd();
}

void hand(){
glBegin(GL_LINES);
glVertex2f(0,0);
glVertex2f(cos(theta)*LENTH,sin(theta)*LENTH);
glEnd();
}

void display(){
glClear(GL_COLOR_BUFFER_BIT);
fill();
border();
hand();
glutSwapBuffers();
}

void keyboard(unsigned char key, int x, int y){
if(key=='q'||key=='Q') exit(0);
}

void runtime(){
theta-=0.0005;
if(theta<=-2*PI) theta+=2*PI;
glutPostRedisplay();
}

void init(){
glClearColor(0.5,0.5,0.5,1.0);
}

int main(int argc,char* argv[]){
glutInit(&argc,argv);
glutInitDisplayMode(GLUT_DOUBLE);

glutInitWindowPosition(300,100);
glutInitWindowSize(500,500);
glutCreateWindow("circle");

init();
glutDisplayFunc(display);
glutReshapeFunc(reshape);
glutIdleFunc(runtime);
glutMainLoop();
}

创建菜单

菜单是我们展示程序的一个很好的工具,方便我们切换程序的模式和功能。glut创建菜单分为下面几步:

  1. 创建菜单回调函数。使用函数 glutCreateMenu(),它的参数如下,其中传入的 int代表菜单元素的索引号。

  2. 添加菜单选项。使用函数 glutAddMenuEntry(),它的参数如下,分别设置菜单选项的显示的名字和索引号。

  3. 绑定触发菜单的按键。使用函数 glutAttachMenu(),传入的参数为某个特殊按键,如鼠标右键 GLUT_RIGHT_BUTTON
  4. 在回调函数中,分别给每个菜单选项设置事件。这里我就设的简单一些了,方便读者理解。

    1
    2
    3
    4
    void mymenu(int value){
    if(value==1) theta=PI/2;
    if(value==2) theta=-PI/2;
    }

运行结果如下: