第一个 OpenGL 程序:旋转的立方体(VS2022 / MFC)

转载请注明出处 https://blog.csdn.net/blackwoodcliff/article/details/132282723

OpenGL API

OpenGL 有两套 API:立即渲染模式(Immediate mode,也就是固定渲染管线,也称为兼容模式)和 核心模式(Core-profile)。
其实这与 GPU 的发展历史有关,最初的 GPU 是不能编程的,也叫固定管线,就是把数据按照固定的通路走完,后来发展出了可编程的 GPU,也叫可编程管线,一开始只能用汇编写 GPU 程序,然后进一步发展出了 GPU 高级编程语言,也就是现在所说的着色语言(Shading Language)。
了解了 GPU 的发展历史,我们自然就明白为什么 OpenGL 会有两套 API 了。立即渲染模式 就是最初 GPU 不能编程时的 API,核心模式 则是使用了着色语言的现代 API。
最新版本的 OpenGL 对立即渲染模式也是支持的,故而也把立即渲染模式称为兼容模式
核心模式 更灵活,效率更高,在当前实际应用中,已经很少有人使用立即渲染模式了。不过立即渲染模式虽然古老低效,但也更简单,作为了解 OpenGL 的基本概念,快速入门,还是很有用的。
本文作为入门教程,为降低学习门槛,因此使用更简单的 立即渲染模式

开发环境

目前网上的 OpenGL 教程大多会使用 GLFWglad 这两个库。GLFW 是一个跨平台的窗口管理库,glad 是一个 OpenGL 函数加载库。
本文为了简单起见,不打算花费精力配置开发环境,所以不会使用 GLFW 和 glad 这两个库。
Windows 内置了对 OpenGL 1.1 的支持,如果使用 兼容模式,完全可以使用 Windows 内置的 OpenGL 1.1 来开发,这样可以省去配置开发环境的工作,聚焦于 OpenGL 本身。

在 MFC 中使用 OpenGL

在开始之前,先了解下如何在 MFC 中使用 OpenGL。

编写 OpenGL 程序,简单来说,要做三件事:初始化 OpenGL、绘制图形、当窗口大小改变时重置视口。
下面分别简要介绍一下,详细说明可参阅这篇文章《MFC中使用OpenGL》。

初始化 OpenGL

MFC 使用 DC(Device Context)绘图,OpenGL 使用 RC(Render Context)绘图,为了将 OpenGL 的图形绘制到 MFC 窗口上,需要在 RC 与 DC 之间建立关联。

Windows 提供了一些扩展函数,用于支持 OpenGL,见《OpenGL 的 Windows 扩展参考》。
可通过调用 OpenGL 的 Windows 扩展函数 wglCreateContext,以 DC 为参数,创建 RC。

在调用 wglCreateContext 创建 RC 之前,需要先设置 DC 的像素格式。
Windows 提供了 PIXELFORMATDESCRIPTOR 结构 来描述像素格式。
我们需要先定义一个 PIXELFORMATDESCRIPTOR 对象来描述像素格式,然后调用 SetPixelFormat 函数设置指定 DC 的像素格式。

在使用 OpenGL 绘图之前,需要先设置当前 RC。
调用 Windows 函数 wglMakeCurrent 设置当前 RC。wglMakeCurrent 的参数 DC 与 wglCreateContext 的参数 DC 可以不是同一个 DC,但这两个 DC 必须位于同一设备上并且具有相同像素格式。
本文没有特别的需求,所以 wglCreateContextwglMakeCurrent 使用同一个 DC。

详见《呈现上下文函数》。

OpenGL 的初始化只需要在窗口创建时执行一次即可。对于对话框程序,可以在 OnInitDialog() 函数里执行。

绘制图形

绘制 OpenGL 图形,是在窗口每次重绘时绘制。对于对话框程序,是在 WM_PAINT 消息的处理函数 OnPaint() 里执行绘图代码。

重置视口大小

OpenGL 绘图,是绘制在视口(Viewport)里。默认的视口大小,是 初始窗口的客户区 大小。
但当窗口大小改变时,OpenGL 视口大小并不会随窗口大小自动改变,所以需要在每次窗口大小改变时,重新设置视口大小。
对于对话框程序,需要在 WM_SIZE 消息的处理函数 OnSize() 里调用 OpenGL 函数 glViewport() 重新设置视口大小。

这篇文章《OpenGL之glViewPort函数的用法》有助于对 OpenGL 视口的理解。

创建 MFC 对话框项目

启动 Visual Studio 2022,选择【创建新项目】:

在这里插入图片描述

在这里插入图片描述
选择 C++ -> Windows -> MFC 应用,然后点击 下一步

在这里插入图片描述
输入 项目名称,然后点击 创建 按钮:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

最后点击 完成 按钮,生成项目如下图所示:

在这里插入图片描述

删掉窗口上自动添加的【TODO】标签、【确定】、【取消】按钮,保存之后,关闭对话框编辑界面。

添加 OpenGL 头文件和库文件

打开 framework.h 文件,在末尾添加下面 4 行代码:

#include <gl\gl.h>			// Header File For The OpenGL32 Library
#include <gl\glu.h>			// Header File For The GLu32 Library

#pragma comment(lib, "OpenGL32.lib")
#pragma comment(lib, "GLU32.lib")

初始化 OpenGL

打开 OpenGLCubeDemoDlg.h 文件,添加下面两个函数声明:

    bool InitializeOpenGL(HDC hDC);		//初始化 OpenGL
    bool SetDCPixelFormat(HDC hDC);		//设置 DC 像素格式

再打开 OpenGLCubeDemoDlg.cpp 文件,在末尾添加上面两个函数的定义:

bool COpenGLCubeDemoDlg::InitializeOpenGL(HDC hDC)
{
	//设置 DC 像素格式
	if (false == SetDCPixelFormat(hDC))
	{
		return false;
	}

	//创建 RC
	HGLRC hRC = wglCreateContext(hDC);
	if (hRC == NULL)
	{
		return false;
	}
	//为当前线程设置 RC 
	if (wglMakeCurrent(hDC, hRC) == FALSE)
	{
		return false;
	}

	glClearDepth(1.0f);
	
	glEnable(GL_TEXTURE_2D);								// Enable Texture Mapping
	glEnable(GL_DEPTH_TEST);								// Enables Depth Testing
	glDepthFunc(GL_LEQUAL);									// The Type Of Depth Testing To Do
	glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST);		// Really Nice Perspective Calculations
	return true;
}
bool COpenGLCubeDemoDlg::SetDCPixelFormat(HDC hDC)
{
	static PIXELFORMATDESCRIPTOR pfd =
	{
		sizeof(PIXELFORMATDESCRIPTOR),	//pfd结构的大小
		1,								//版本号
		PFD_DRAW_TO_WINDOW |			//支持在窗口中绘图
		PFD_SUPPORT_OPENGL |			//支持OpenGL
		PFD_DOUBLEBUFFER,				//支持双缓冲
		PFD_TYPE_RGBA,					//RGBA颜色模式
		32,								//32位颜色深度
		0, 0, 0, 0, 0, 0,				//忽略颜色位
		0,								//没有非透明度缓存
		0,								//忽略移位位
		0,								//无累计缓存
		0, 0, 0, 0,						//忽略累计位
		32,								//32位深度缓存
		0,								//无模板缓存
		0,								//无辅助缓存
		PFD_MAIN_PLANE,					//主层
		0,								//保留
		0, 0, 0							//忽略层,可见性和损毁掩模
	};

	//得到 DC 最匹配的像素格式
	int pixelFormat = ChoosePixelFormat(hDC, &pfd);
	if (0 == pixelFormat)
	{
		//如果没有找到,就调用 DescribePixelFormat 函数来选择索引值为 1 的像素格式
		pixelFormat = 1;
		if (DescribePixelFormat(hDC, pixelFormat, sizeof(PIXELFORMATDESCRIPTOR), &pfd) == 0)
		{
			MessageBox(_T("ChoosePixelFormat 失败"));
			return false;
		}
	}
	//设置 DC 像素格式
	if (SetPixelFormat(hDC, pixelFormat, &pfd) == FALSE)
	{
		MessageBox(_T("SetPixelFormat 失败"));
		return false;
	}
	return true;
}

注意上面像素格式的定义,最主要的是第三个参数 dwFlags,这里设置了三个值 PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER,每个值的作用,请见代码注释或 PIXELFORMATDESCRIPTOR 文档。

然后找到 COpenGLCubeDemoDlg::OnInitDialog() 函数,添加下面这行代码:

InitializeOpenGL(this->GetDC()->GetSafeHdc());

做完上面的工作后,就完成了 OpenGL 的初始化。

此时运行程序,还是一个空白窗口。
下面进入本文的重点,OpenGL 绘图。

画一个正方形

在绘制立方体前,先画一个正方形练练手,熟悉一下 OpenGL 的绘图步骤。

OpenGLCubeDemoDlg.h 文件里,添加函数声明:

void DrawRect();

OpenGLCubeDemoDlg.cpp 文件里,在末尾添加函数定义:

void COpenGLCubeDemoDlg::DrawRect()
{
	glBegin(GL_QUADS);
	glVertex2f(-0.5, 0.5);
	glVertex2f(-0.5, -0.5);
	glVertex2f(0.5, -0.5);
	glVertex2f(0.5, 0.5);
	glEnd();
}

然后找到 COpenGLCubeDemoDlg::OnPaint() 函数,在 CDialogEx::OnPaint(); 语句后面,添加下面代码:

		glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
		glLoadIdentity();
		DrawRect();
		glFlush();
		SwapBuffers(wglGetCurrentDC());

Ctrl+F5 运行程序,如下图:

在这里插入图片描述

下面重点说一说 DrawRect() 函数。
DrawRect() 里共有 6 行代码,使用了 3 个函数。
其中 glBegin()glEnd() 要成对出现,glBegin() 的参数 GL_QUADS 表示要画一个四边形。
有关 glBegin() 函数及其参数的详细说明,请见 glBegin 函数
对于 glBegin() 的参数,下面这张图,看起来更直观:

在这里插入图片描述

另一个是 glVertex2f() 函数,用于指定图形的顶点。由于正方形有 4 个顶点,所以调用了 4glVertex2f() 函数,指定了 4 个顶点。
glVertex 有一系列函数,只是参数不同,在函数名里以参数个数和参数类型作为后缀来区分,详细说明请见 glVertex 函数

有关 OpenGL 函数的命名规则,请见下图:

在这里插入图片描述

glVertex2f() 函数的参数,是顶点的 (x, y) 坐标,这里设置的都是 0.5。要想理解 0.5 的含义,需要先搞清楚 OpenGL 的坐标系。

OpenGL 坐标系

OpenGL 是右手坐标系,X 轴正方向指向屏幕右侧,Y 轴正方向指向屏幕上方,Z 轴正方向指向屏幕外,如下图:

在这里插入图片描述

OpenGL 坐标系的原点 (0, 0, 0) 点位于屏幕中心,屏幕左下角的坐标是 (-1, -1, 0),右上角的坐标是 (1, 1, 0),如下图:

在这里插入图片描述

所以,DrawRect() 函数里指定的 4 个顶点,分别位于距离屏幕各边的 1/4 处。从上面的截图里,我们看到,也确实是这样的位置。

需要说明的是,指定顶点时,各个顶点需要按照顺时针方向或逆时针方向顺序排列。

DrawRect() 函数里,是从左上角开始,按照逆时针方向指定的。

	glBegin(GL_QUADS);
	glVertex2f(-0.5, 0.5);		//左上角 
	glVertex2f(-0.5, -0.5);		//左下角
	glVertex2f(0.5, -0.5);		//右下角
	glVertex2f(0.5, 0.5);		//右上角
	glEnd();

在这里,我们要画的是一个正方形,从指定的顶点位置来看,也应该是正方形,但从上面的截图里看到的,却是长方形。
其实稍微想一下,我们就会想到,如果把窗口变成正方形,那么里面画的图形,就也是正方形了。

在这里插入图片描述

但这并不是我们想要的结果,我们希望不论窗口大小如何,里面画的始终都是正方形。这个问题,留待重置视口大小时一并解决。

改变默认颜色

从上面的截图中可以看到,OpenGL 默认的背景色是 黑色,前景色是 白色

修改背景色,可在调用 glClear() 函数前,先调用 glClearColor() 函数。
如在 COpenGLCubeDemoDlg::OnPaint() 函数中,添加 glClearColor(0.0f, 0.05f, 0.15f, 1.0f); 语句,可将背景色改为夜空蓝色。

修改前景色,可在指定点坐标之前,先调用 glColor 函数。glColor 也是一系列函数,详见 glColor 函数
如在 COpenGLCubeDemoDlg::DrawRect() 函数里,在 glBegin(GL_QUADS); 之前,添加 glColor3ub(96, 0, 0); 语句,可将四边形改为深红色。

在这里插入图片描述

重置视口大小

此时如果改变窗口大小,会发现四边形并不在窗口中央,这是由于改变窗口大小时,没有同时改变 OpenGL 视口导致的。

点击菜单 视图 -> 类视图,打开 类视图 窗口。在 COpenGLCubeDemoDlg 节点上,点击鼠标右键,弹出快捷菜单:

在这里插入图片描述

在快捷菜单上,选择 属性 项,打开 属性 窗口。
属性 窗口的工具栏上,点击 消息 按钮,然后找到 WM_SIZE 消息,点击右侧下拉箭头,在下拉框里选择 <add>OnSize,如下图:

在这里插入图片描述

此时会自动在 COpenGLCubeDemoDlg.cpp 文件里添加 OnSize 函数定义。

然后在 OnSize() 函数里,添加 glViewport(0, 0, cx, cy); 语句,再重新运行程序,可看到改变窗口大小后,四边形仍然位于窗口中央。

void COpenGLCubeDemoDlg::OnSize(UINT nType, int cx, int cy)
{
	CDialogEx::OnSize(nType, cx, cy);
	
	glViewport(0, 0, cx, cy);
}

但是多操作几次会发现,并不是每次改变窗口大小时,四边形都能位于窗口中央。感觉这是窗口刷新不及时导致的,在 glViewport(0, 0, cx, cy); 之后添加一行 Invalidate(); 强制刷新窗口,然后重新运行程序,再次调整窗口大小,发现这回正常了。

还有一个问题,就是我们想画的是正方形,但目前为止看到的都是长方形。
有两个办法可以解决这个问题,使用任何一个都可以:

  • 根据窗口宽高比例调整视口位置,并设置视口宽高相同。
void COpenGLCubeDemoDlg::OnSize(UINT nType, int cx, int cy)
{
	CDialogEx::OnSize(nType, cx, cy);

	GLint nX = 0, nY = 0, nWidth = 0;
	if (cx < cy)
	{
		nY = (cy - cx) / 2;
		nWidth = cx;
	}
	else
	{
		nX = (cx - cy) / 2;
		nWidth = cy;
	}
	glViewport(nX, nY, nWidth, nWidth);

	Invalidate();
}
  • 仍保持视口与窗口大小相同,使用 glOrthogluOrtho2D 函数设置视景体。
void COpenGLCubeDemoDlg::OnSize(UINT nType, int cx, int cy)
{
	CDialogEx::OnSize(nType, cx, cy);

	glViewport(0, 0, cx, cy);
	
	if (cy == 0)									//防止除0
		cy = 1;
	GLfloat scale = (GLfloat)cx / (GLfloat)cy;		//窗口协调比例

	glMatrixMode(GL_PROJECTION);				//重置投影矩阵,告诉OpenGL接下来做投影变换
	glLoadIdentity();

	if (cx < cy)
	{
		gluOrtho2D(-1.0, 1.0, -1.0 / scale, 1.0 / scale);
	}
	else
	{
		gluOrtho2D(-1.0 * scale, 1.0 * scale, -1.0, 1.0);
	}

	//告诉openGL未来的转换将影响绘制的图形
	glMatrixMode(GL_MODELVIEW);
	glLoadIdentity();

	Invalidate();
}

这里必须说明一下,在上面的代码中可以看到,在调用 gluOrtho2D 之前和之后,都调用了 glMatrixModeglLoadIdentity 函数。
glMatrixMode 函数 指明接下来的代码操作的是哪个矩阵,详细说明可见这篇文章《OpenGL之glMatrixMode函数的用法》。
glLoadIdentity 函数 将当前矩阵复位到初始状态。

大家可能看到了,程序启动后,窗口默认是最大化的,此时显示的四边形仍然还是长方形。如果你看到了这个现象,那不要紧,只要把 COpenGLCubeDemoDlg::OnInitDialog() 函数里的 ShowWindow(SW_MAXIMIZE); 一行挪到 InitializeOpenGL(this->GetDC()->GetSafeHdc()); 后面即可。

下面看下最终效果,然后进入本文的正题:绘制立方体。

在这里插入图片描述

绘制立方体

通过上面画正方形,我们对 OpenGL 的绘图步骤有了初步了解,现在开始绘制一个立方体。有了画正方形的知识,再绘制立方体就容易多了。
首先来说,立方体是由六个面构成的,每个面都是一个正方形。其次,相对于正方形,立方体需要指定顶点的 Z 坐标。

OpenGLCubeDemoDlg.h 文件里,添加函数声明:

void DrawCube();

OpenGLCubeDemoDlg.cpp 文件里,在末尾添加函数定义:

void COpenGLCubeDemoDlg::DrawCube()
{
	glBegin(GL_QUADS);

	// Front Face
	glColor3ub(128, 0, 0);		//红
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glVertex3f(0.5f, 0.5f, 0.5f);
	glVertex3f(-0.5f, 0.5f, 0.5f);

	// Back Face
	glColor3ub(128, 128, 0);	//黄
	glVertex3f(-0.5f, -0.5f, -0.5f);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Top Face
	glColor3ub(0, 0, 128);		//蓝
	glVertex3f(-0.5f, 0.5f, 0.5f);
	glVertex3f(0.5f, 0.5f, 0.5f);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Bottom Face
	glColor3ub(128, 0, 128);	//紫
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glVertex3f(-0.5f, -0.5f, -0.5f);

	// Left Face
	glColor3ub(0, 128, 0);		//绿
	glVertex3f(-0.5f, -0.5f, -0.5f);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glVertex3f(-0.5f, 0.5f, 0.5f);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Right face
	glColor3ub(0, 128, 128);	//青
	glVertex3f(0.5f, -0.5f, 0.5f);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glVertex3f(0.5f, 0.5f, 0.5f);

	glEnd();
}

DrawCube() 函数的代码与 DrawRect() 对比一下,可以看到 DrawCube() 里共指定了 24 个顶点,每个面 4 个顶点。而且指定顶点时不再使用 glVertex2f() 函数,而是使用 glVertex3f() 函数,增加了 Z 坐标。

由于之前绘制的是正方形,接下来还需要修改一下 OnPaint()OnSize() 函数,改为绘制立方体。
首先把 OnPaint() 函数中的 DrawRect(); 一行替换为 DrawCube();,然后再删改 OnSize() 函数代码如下:

void COpenGLCubeDemoDlg::OnSize(UINT nType, int cx, int cy)
{
	CDialogEx::OnSize(nType, cx, cy);

	glViewport(0, 0, cx, cy);
	Invalidate();
}

重新编译并运行程序,显示窗口如下图:

在这里插入图片描述

在这个窗口里,我们没有看到立方体,只看到了一个黄色的长方形。要理解这是怎么回事,需要先了解下 视点

  • 视点:即 观察点,也可以理解为 人眼摄像机 的位置,默认坐标是 (0,0,0),即坐标系的原点,也是我们绘制的这个立方体的中心点。对于 人眼摄像机,还要有 视线方向,默认的视线方向朝向 Z 轴的负方向,即屏幕里面。

了解了 视点 之后,还需要了解一点:

OpenGL 绘制的物体是空心的。

现在我们想象一下,我们的眼睛处在视点的位置,也就是立方体的内部,位于正中心点,向 Z 轴负方向(指向屏幕内部)看去,立方体的前面在我们脑后,立方体的背面在视线前方,那么我们看到的黄色长方形正是立方体的背面。

那如何我们才能看到立方体呢?还需要再了解下 视景体

  • 视景体:也称 视锥体,可以理解为 人眼摄像机 的视野范围。视野外的物体我们是看不到的,同样的,视景体外的物体,OpenGL 也不会绘制出来。OpenGL 默认的视景体(x: [-1, 1], y: [-1, 1], z: [-1, 1]) 的范围。

为了看到完整的立方体,我们可以使用 gluLookAt 函数把 视点 移到立方体的外面。不过这里我们使用另一个办法,通过 投影变换视景体移到眼前,然后再把立方体移动到视景体里。

投影有两种:正交投影透视投影。正交投影的变换函数是 glOrtho,前面已经使用过了。正交投影没有透视效果,物体在远处和近处,大小是一样的。为了使立方体看起来更有立体感,我们将使用透视投影。

透视投影 的变换函数有 glFrustumgluPerspective。这篇文章《OpenGL 入门纪录–2 .透视函数glFrustum(), gluPerspective()函数用法和glOrtho()函数用法》对这两个函数有更详细的介绍。

下面两幅图,对这两个函数的参数做了直观的说明,仔细理解这两幅图,对于我们正确理解这两个函数参数的含义非常有帮助。

glFrustum()参数含义

gluPerspective()参数含义

上面的图描述了 glFrustum 函数 参数的含义,下面的图描述了 gluPerspective 函数 参数的含义。

注意:这两个函数的最后两个参数 zNear 和 zFar 分别表示相机到近裁面和远裁面的距离,始终为正值,必须大于 0。

这两个函数可以相互转换,可以参考这篇文章《OpenGL中gluPerspective函数和glFrustum函数的关系》。

我们接下来将使用相对简单的 gluPerspective 函数 进行投影变换(改变视景体的大小和位置)。
修改 OnSize() 函数如下:

void COpenGLCubeDemoDlg::OnSize(UINT nType, int cx, int cy)
{
	CDialogEx::OnSize(nType, cx, cy);

	glViewport(0, 0, cx, cy);

	glMatrixMode(GL_PROJECTION);		//重置投影矩阵,告诉OpenGL接下来做投影变换
	glLoadIdentity();

	gluPerspective(45.0f, (GLfloat)cx / (GLfloat)cy, 1.0f, 100.0f);

	glMatrixMode(GL_MODELVIEW);
	glLoadIdentity();

	Invalidate();
}

这里再强调一下,在做投影变换前和变换后都要调用 glMatrixModeglLoadIdentity 函数 。

现在再重新编译、运行程序,会发现不但没有立方体,而且原来的黄色长方形也不见了。
这是由于经过投影变换后,视景体已经移到了 Z 轴的 [-1, -100] 之间,而我们绘制的立方体还位于 Z 轴的 [0.5, -0.5] 之间。因为立方体不在视景体内,所以 OpenGL 根本就不会绘制这个立方体。

接下来,我们在 DrawCube() 函数的 glBegin(GL_QUADS); 语句前添加下面一行:

glTranslatef(0.0f, 0.0f, -2.5);

glTranslatef 是平移变换函数,这行语句的作用是将立方体向 Z 轴的负方向平移 2.5 个单位距离。由于立方体原来位于 Z 轴的 [0.5, -0.5] 之间,移动 -2.5 距离后,立方体的位置就变为了 Z 轴的 [-2, -3] 之间,这样就把立方体移到了视景体里面。

再重新编译、运行程序,会看到一个红色的正方形,对照代码可以发现,这个红色的正方形是立方体的正面:

在这里插入图片描述

通过透视投影和平移变换之后,虽然我们还没有看到完整的立方体,但其实已经前进了一大步。现在立方体已经在视景体里了,只是由于视点的视线方向正对立方体中心,而且透视投影使得立方体的背面更小,所以立方体的其余部分,是被正面遮挡了,因此我们才看不到。现在只要旋转一下立方体,我们就可以看到其余部分了。

使用箭头按键旋转立方体

OpenGLCubeDemoDlg.h 文件里,添加如下变量声明,分别表示绕 X 轴和绕 Y 轴的旋转角度:

	float m_rotationX = 0.0f;
	float m_rotationY = 0.0f;

然后再修改 DrawCube() 函数,在 glBegin(GL_QUADS); 语句前添加如下两行,使立方体绕 X 轴和 Y 轴旋转指定角度:

	glRotatef(m_rotationX, 1.0f, 0.0f, 0.0f);
	glRotatef(m_rotationY, 0.0f, 1.0f, 0.0f);

接下来增加按键处理,上下箭头改变绕 X 轴旋转的角度,左右箭头改变绕 Y 轴旋转的角度,Esc键取消旋转。

点击菜单 视图 -> 类视图,打开 类视图 窗口。在 COpenGLCubeDemoDlg 节点上,点击鼠标右键,弹出快捷菜单。在快捷菜单上,选择 属性 项,打开 属性 窗口。
属性 窗口的工具栏上,点击 重写 按钮,然后找到 PreTranslateMessage 项,点击右侧下拉箭头,在下拉框里选择 <add>PreTranslateMessage,如下图:

在这里插入图片描述

此时会自动在 COpenGLCubeDemoDlg.cpp 文件里添加 PreTranslateMessage 函数定义。

修改 PreTranslateMessage 函数如下:

BOOL COpenGLCubeDemoDlg::PreTranslateMessage(MSG* pMsg)
{
	if (pMsg->message == WM_KEYDOWN)
	{
		if (pMsg->wParam == VK_LEFT)
		{
			m_rotationY -= 1;
		}
		else if (pMsg->wParam == VK_RIGHT)
		{
			m_rotationY += 1;
		}
		else if (pMsg->wParam == VK_UP)
		{
			m_rotationX -= 1;
		}
		else if (pMsg->wParam == VK_DOWN)
		{
			m_rotationX += 1;
		}
		else if (pMsg->wParam == VK_ESCAPE)
		{
			m_rotationX = 0.0f;
			m_rotationY = 0.0f;
			return FALSE;
		}
	}
	return CDialogEx::PreTranslateMessage(pMsg);
}

然后重新编译、运行程序,按键盘上的上、下、左、右箭头旋转立方体,可是发现立方体并没有旋转,看到的仍然是一个红色的正方形。

这个问题有两个解决办法:

  1. 去掉 OnPaint() 函数里的 CDialogEx::OnPaint(); 一行;
  2. PreTranslateMessage(MSG* pMsg) 函数里,每次改变旋转角度后调用 Invalidate();

第一种方法,去掉 CDialogEx::OnPaint(); 后,窗口会不停刷新,所以改变旋转角度后,立即就绘制出了旋转后的立方体。
第二种方法,添加 Invalidate(); ,可在改变旋转角度后,强制窗口刷新。

为简单起见,使用第一种方法,去掉 OnPaint() 函数里的 CDialogEx::OnPaint(); 。(其实这么做是不对的,后面会讲到

再次重新编译、运行程序,按键盘上的上、下、左、右箭头旋转立方体,这回终于看到真正的立方体了。

在这里插入图片描述

深度测试

InitializeOpenGL() 函数里,有这样一行代码,我们没有仔细讲解:

glEnable(GL_DEPTH_TEST);

现在大家可以找到这行代码,把它注释掉,然后重新编译并运行程序,按键盘的上、下、左、右箭头旋转立方体,看看与先前有什么不同。
想仔细了解的同学,可以打开这个教程 坐标系统,在页面中搜索 Z缓冲 字样,然后仔细阅读相关内容。

添加纹理

上面我们绘制了一个彩色的立方体,我们还可以给立方体贴上图片,使细节更丰富。

可以阅读这个教程,了解 纹理

这个教程推荐使用 stb_image.h 加载图片。stb_image.h 是一个非常流行的单头文件图像加载库,它能够加载大部分流行的文件格式。stb_image.h 可以在这里下载。

下载 stb_image.h 文件,并将它复制到 D:\OpenGLCubeDemo\OpenGLCubeDemo 文件夹内。

解决方案资源管理器 中,选中工具栏中的 显示所有文件 按钮。再在下面的文件列表中,在 stb_image.h 节点上点击鼠标右键,在弹出菜单中选择 包括在项目中(J)。然后取消工具栏中 显示所有文件 按钮的选中状态。

在这里插入图片描述

OpenGLCubeDemoDlg.cpp 文件顶部添加:

#define STBI_WINDOWS_UTF8
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

OpenGLCubeDemoDlg.h 文件里,添加变量和函数声明:

UINT m_glTexture = 0;
UINT LoadGLTexture();

COpenGL11DemoDlg::OnInitDialog() 函数里调用 LoadGLTexture() 加载纹理:

m_glTexture = LoadGLTexture();

函数 LoadGLTexture() 定义如下:

UINT COpenGLCubeDemoDlg::LoadGLTexture()
{
	stbi_set_flip_vertically_on_load(true);
	
	int width, height, nrChannels;
	unsigned char* data = stbi_load(IMAGE_PATH, &width, &height, &nrChannels, 0);

	unsigned int texture;
	glGenTextures(1, &texture);
	glBindTexture(GL_TEXTURE_2D, texture);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);   //在纹理被放大时使用线性过滤
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);  //在纹理被缩小时使用邻近过滤
	glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);

	stbi_image_free(data);
	return texture;
}

glTexImage2D() 函数用于将内存中的数据拷贝到 OpenGL 纹理单元中,参数很多,我们尤其需要注意第 3 个和第 7 个参数:

  • 第 3 个参数,告诉 OpenGL 我们希望把纹理储存为何种格式
  • 第 7 个参数,是指源图的格式
  • 当纹理为 24 位图片时,这两个参数应设置为 GL_RGB
  • 当纹理为 32 位图片时,这两个参数应设置为 GL_RGBA

对于纹理图片,有以下几点经验可供参考:

  • 尽量使用 JPG 或 PNG 格式的图片
  • 尽量使用 72 dpi 或 96 dpi 的 24 位或 32 位的图片
  • 图片的宽度和高度应为 2 的整数倍
  • 对于 24 位图片,高度应为宽度的整数倍,或者宽度为高度的整数倍

应用纹理

我们接下来使用下面这张图片,将其贴到立方体的每个面上。

在这里插入图片描述

这需要修改 DrawCube() 函数,调用 glBindTexture() 函数 绑定纹理,并在每个顶点处使用 glTexCoord 函数 指定纹理坐标。

void COpenGLCubeDemoDlg::DrawCube()
{
	glTranslatef(0.0f, 0.0f, -2.5);

	glRotatef(m_rotationX, 1.0f, 0.0f, 0.0f);
	glRotatef(m_rotationY, 0.0f, 1.0f, 0.0f);

	glBindTexture(GL_TEXTURE_2D, m_glTexture);

	glBegin(GL_QUADS);

	// Front Face
	glTexCoord2i(0, 0);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glTexCoord2i(1, 0);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glTexCoord2i(1, 1);
	glVertex3f(0.5f, 0.5f, 0.5f);
	glTexCoord2i(0, 1);
	glVertex3f(-0.5f, 0.5f, 0.5f);

	// Back Face
	glTexCoord2i(1, 0);
	glVertex3f(-0.5f, -0.5f, -0.5f);
	glTexCoord2i(0, 0);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glTexCoord2i(0, 1);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glTexCoord2i(1, 1);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Top Face
	glTexCoord2i(0, 0);
	glVertex3f(-0.5f, 0.5f, 0.5f);
	glTexCoord2i(1, 0);
	glVertex3f(0.5f, 0.5f, 0.5f);
	glTexCoord2i(1, 1);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glTexCoord2i(0, 1);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Bottom Face
	glTexCoord2i(0, 0);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glTexCoord2i(1, 0);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glTexCoord2i(1, 1);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glTexCoord2i(0, 1);
	glVertex3f(-0.5f, -0.5f, -0.5f);

	// Left Face
	glTexCoord2i(0, 0);
	glVertex3f(-0.5f, -0.5f, -0.5f);
	glTexCoord2i(1, 0);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glTexCoord2i(1, 1);
	glVertex3f(-0.5f, 0.5f, 0.5f);
	glTexCoord2i(0, 1);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Right face
	glTexCoord2i(0, 0);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glTexCoord2i(1, 0);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glTexCoord2i(1, 1);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glTexCoord2i(0, 1);
	glVertex3f(0.5f, 0.5f, 0.5f);

	glEnd();
}

这里需要着重说一下纹理坐标。纹理坐标的 (0, 0) 点位于左下角,(1, 1) 点位于右上角,如下图所示:

在这里插入图片描述

重新编译、运行程序,按键盘的上、下、左、右箭头旋转立方体,效果如下图:

在这里插入图片描述

换一个纹理

我们换下面这张图片作为纹理,使立方体看起来像一个骰子。

在这里插入图片描述

需要修改 DrawCube() 函数内的每个顶点的纹理坐标。注意,由于要使用小数,纹理坐标函数换成了 glTexCoord2f()

void COpenGLCubeDemoDlg::DrawCube()
{
	glTranslatef(0.0f, 0.0f, -2.5);

	glRotatef(m_rotationX, 1.0f, 0.0f, 0.0f);
	glRotatef(m_rotationY, 0.0f, 1.0f, 0.0f);

	glBindTexture(GL_TEXTURE_2D, m_glTexture);

	glBegin(GL_QUADS);

	// Front Face
	glTexCoord2f(0.0f, 0.5f);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glTexCoord2f(0.33f, 0.5f);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glTexCoord2f(0.33f, 1.0f);
	glVertex3f(0.5f, 0.5f, 0.5f);
	glTexCoord2f(0.0f, 1.0f);
	glVertex3f(-0.5f, 0.5f, 0.5f);

	// Back Face
	glTexCoord2f(0.66f, 0.0f);
	glVertex3f(-0.5f, -0.5f, -0.5f);
	glTexCoord2f(1.0f, 0.0f);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glTexCoord2f(1.0f, 0.5f);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glTexCoord2f(0.66f, 0.5f);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Top Face
	glTexCoord2f(0.33f, 0.5f);
	glVertex3f(-0.5f, 0.5f, 0.5f);
	glTexCoord2f(0.66f, 0.5f);
	glVertex3f(0.5f, 0.5f, 0.5f);
	glTexCoord2f(0.66f, 1.0f);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glTexCoord2f(0.33f, 1.0f);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Bottom Face
	glTexCoord2f(0.33f, 0.0f);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glTexCoord2f(0.66f, 0.0f);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glTexCoord2f(0.66f, 0.5f);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glTexCoord2f(0.33f, 0.5f);
	glVertex3f(-0.5f, -0.5f, -0.5f);

	// Left Face
	glTexCoord2f(0.66f, 0.5f);
	glVertex3f(-0.5f, -0.5f, -0.5f);
	glTexCoord2f(1.0f, 0.5f);
	glVertex3f(-0.5f, -0.5f, 0.5f);
	glTexCoord2f(1.0f, 1.0f);
	glVertex3f(-0.5f, 0.5f, 0.5f);
	glTexCoord2f(0.66f, 1.0f);
	glVertex3f(-0.5f, 0.5f, -0.5f);

	// Right face
	glTexCoord2f(0.0f, 0.0f);
	glVertex3f(0.5f, -0.5f, 0.5f);
	glTexCoord2f(0.33f, 0.0f);
	glVertex3f(0.5f, -0.5f, -0.5f);
	glTexCoord2f(0.33f, 0.5f);
	glVertex3f(0.5f, 0.5f, -0.5f);
	glTexCoord2f(0.0f, 0.5f);
	glVertex3f(0.5f, 0.5f, 0.5f);

	glEnd();
}

再次重新编译、运行程序,按键盘的上、下、左、右箭头旋转立方体,效果如下图:

在这里插入图片描述

自动旋转

在上面代码中,我们通过按键盘的上、下、左、右箭头改变 m_rotationXm_rotationY 变量的值,来旋转立方体。
下面我们使用定时器,定时改变 m_rotationXm_rotationY 变量的值,来实现立方体自动旋转。

解决方案资源管理器 中,在项目名称 OpenGLCubeDemo 上点击鼠标右键,在弹出菜单上选择 类向导(Z)…,打开 类向导 对话框。

在这里插入图片描述

类名 下拉框中选择 COpenGLCubeDemoDlg,切换到 消息 选项卡,在下面消息列表中,选中 WM_TIMER 消息,然后点击右侧的 添加处理程序(A) 按钮。
此时会在 现有处理程序 列表里添加消息处理函数 OnTimer,点击 确定 按钮,会自动在 COpenGLCubeDemoDlg.cpp 文件里添加 OnTimer 函数定义。

修改 OnTimer 函数,定时器每触发一次,立方体分别绕 X 轴和 Y 轴各旋转 1 度:

void COpenGLCubeDemoDlg::OnTimer(UINT_PTR nIDEvent)
{
	m_rotationX += 1;
	m_rotationY += 1;
	Invalidate();

	CDialogEx::OnTimer(nIDEvent);
}

OpenGLCubeDemoDlg.h 文件里,添加如下变量声明,表示定时器是否启动:

bool  m_bTimer = false;

修改 PreTranslateMessage 函数,添加空格键处理,通过按空格键启动/停止定时器:

else if (pMsg->wParam == VK_SPACE)
{
	if (false == m_bTimer)
	{
		SetTimer(1, 100, NULL);
		m_bTimer = true;
	}
	else
	{
		KillTimer(1);
		m_bTimer = false;
	}
}

重新编译、运行程序,发现按空格键后,定时器并没有启动。这是什么原因呢?

还记得最初按上、下、左、右箭头时,立方体没有旋转吧?我们当时选择了去掉 OnPaint() 函数里的 CDialogEx::OnPaint(); 代码来解决这个问题,就是这个操作,导致了现在定时器没有启动,所以我们不能用这个方法了,而应该改用第二种方法,在每次改变旋转角度后调用 Invalidate(); 强制刷新窗口。

至于这个问题产生的原因,就有些复杂了,涉及到了 MFC 和 Win32 的内部逻辑,不感兴趣的小伙伴可以直接跳过。

先从 MFC 说起,CDialogExCDialog 的派生类,实际 CDialogEx::OnPaint() 是调用的 CDialog::OnPaint()
我们到 MFC 源码的 dlgcore.cpp 文件里看下 CDialog::OnPaint() 函数的定义,在 OnPaint() 函数的第一行是一个 CPaintDC 变量定义:CPaintDC dc(this);
再到 wingdi.cpp 里看下 CPaintDC 的构造函数和析构函数,在 CPaintDC 的构造函数里调用了 BeginPaint(),在析构函数里调用了 EndPaint()
关键就在这里,也就是说,我们去掉 OnPaint() 函数里的 CDialogEx::OnPaint(); 一行,就会导致不执行 BeginPaint(),不执行 BeginPaint() 会产生什么后果呢?

来看看微软对 WM_PAINT 消息的说明:

BeginPaint 将窗口的更新区域设置为 NULL。 这会清除该区域,阻止其生成后续 WM_PAINT 消息。 如果应用程序处理 WM_PAINT 消息,但不调用 BeginPaint 或以其他方式清除更新区域,则只要该区域不为空,应用程序将继续接收 WM_PAINT 消息。 在所有情况下,应用程序必须在从 WM_PAINT 消息返回之前清除更新区域。

就是说,不执行 BeginPaint,就会不停接收 WM_PAINT 消息,窗口就会不停刷新。

另外,WM_TIMER 是低优先级消息,当窗口不停处理 WM_PAINT 消息时,WM_TIMER 就得不到及时处理,所以定时器就没有触发。

现在我们应该明白了,去掉 OnPaint() 函数里的 CDialogEx::OnPaint(); 是错误的,不应该这样做。

MFC 源码默认位于类似这样的目录:C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.36.32532\atlmfc\src\mfc

销毁资源

参照添加 WM_TIMER 消息处理函数的方法,添加 WM_DESTROY 消息的处理函数。

void COpenGLCubeDemoDlg::OnDestroy()
{
	CDialogEx::OnDestroy();

	wglMakeCurrent(NULL, NULL);			// Make the rendering context not current 

	if (m_glTexture != 0)
	{
		glDeleteTextures(1, &m_glTexture);		// If valid gltexture delete it
	}
}

更进一步

这篇文章只是一篇 OpenGL 入门教程,用于了解 OpenGL 的一些基本概念,如果大家想深入学习 OpenGL,接下来可以学习这个教程 LearnOpenGL CN

源码下载

参考

  • 24
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值