第一章|第一章 Windows 窗口编程

前言

本系列课程旨在讲述基于 OpenGL 的 GUI 编程,着重讲解 OpenGL 矢量绘图功能,以及如何建立一个 OpenGL GUI 框架。GUI 是图形用户界面的简称,基于 OpenGL 的 GUI 是指使用了显卡进行 3D 加速功能绘制的图形用户界面,特点是速度快,可 3D 化,游戏可嵌入性,跨平台等。与之对应的是操作系统提供的平台 GUI 接口,比如 Windows 上的 GDI 和 MacOS 上的 Cocoa。不过因为 OpenGL 要绘制到窗口上,本课程同时还讲解了 Windows 及 MacOS 窗口编程,以及如何分别在两个平台上建立 OpenGL 绘制上下文。本课程基于 C++ 11 编程语言,在学习完本课程后,你会学到一些矢量图形,文字绘制技巧,甚至是一些 C++ 编程知识,另外建立一个基于 OpenGL 的 C++ 11 GUI 框架也是本课程的重点。课程结束后,你可以将这个框架用在游戏中,或者用它开发游戏引擎编辑器,或者其他 GUI 程序。
开发环境 首先,我们来先了解一下如何在 Windows 上建立 GUI 窗口,MacOS 的课程会在后面的章节里讲到。这里先声明一下,我们课程的开发平台是 Windows 10,IDE 是 Visual Studio 2017(下文简称VS)。接下来我们用 VS 新建解决方案。
1.新建空白解决方案:OpenGLGUI 第一章|第一章 Windows 窗口编程
文章图片
2.添加一个空项目:Win32GUI 第一章|第一章 Windows 窗口编程
文章图片
3.为项目 Win32GUI 添加源文件:Win32GUI.cpp 第一章|第一章 Windows 窗口编程
文章图片
编写代码 main 函数
#include #include using namespace std::this_thread; using namespace std::chrono; int main(int argc, char* argv[]) { // 调用 C++ 11 多线程库的线程睡眠函数, // 让程序睡眠 3 秒 sleep_for(milliseconds(3000)); return 0; }

设置项目为 Debug x64 配置
第一章|第一章 Windows 窗口编程
文章图片
编译运行以上代码之后你会得到一个如下的窗口,并且 3 秒后窗口将会消失
第一章|第一章 Windows 窗口编程
文章图片
看起来不像 GUI 窗口的控制台窗口 可能你会奇怪,难道我们的 OpenGL 要往这个黑色的控制台窗口绘制东西吗?当然不是,CPP 文件里面是我们熟悉的 main 函数,运行后出来这个黑色的窗口,不足为奇。接下来我们要对项目设置稍微做一些改动,进入 GUI 模式。
第一章|第一章 Windows 窗口编程
文章图片
修改子系统 默认的子系统为空,实际上相当于 CONSOLE,所以我们在初学 C/C++ 时常看到的控制台窗口是怎么来的,现在你应该清楚了吧。把子系统改成 WINDOWS,再编译运行一下(请注意我们运行和修改项目的平台是x64)。
运行结果不出所料会出现如下错误:
第一章|第一章 Windows 窗口编程
文章图片
谁是真正的入口函数 准确的理解以上错误需要一番解释,首先每一个可执行程序,比如我们编译的结果: 一个 exe 文件,这个文件包含了资源和代码等信息,那么代码的入口点是什么呢,或者说当 exe 文件被加载到内存后被首先运行的函数是什么呢,你可能会不假思索的回答: main。下面我用如下的代码让你反思一下这个答案。
class SomeClass { public: SomeClass() { printf("SomeClass\n"); } }; // 全局对象 SomeClass obj; int main(int argc, char* argv[]) { printf("main\n"); return 0; }

以上两句 printf 执行顺序如何?如果你稍微思考一下,我估计没人会回答先打出来 main,正确答案是先SomeClassmain那么SomeClass的构造函数是谁执行的呢?实际上在我们的 main 函数执行之前有太多的事情要做,才能保证我们的程序正确,这些事情都是由一个称为C/C++运行时来负责,那么很自然,exe 的入口函数实际上是运行时里的某个函数。在 Windows 平台上有两个这样的函数: mainCRTStartupWinMainCRTStartup,这两个函数分别对应于 CONSOLE 子系统和 WINDOWS 子系统下默认的入口函数,并且可以互换。那么这两个函数的区别是什么呢?实际上区别只有一个,前者会调用叫main的函数,而后者会调用叫 WinMain 的函数,而我们,作为开发者,需要在我们的 CPP 源文件中实现它们。由于我们刚才修改了子系统,导致WinMainCRTStartup成为了入口函数,所以刚才我们得到的错误信息,此时应该很明了了: 我们没有提供WinMain函数,但我并不会提供WinMain函数,取而代之,我决定修改入口函数为mainCRTStartup。实际上这两种方法都可以,但我更喜欢main函数,而且也省去了我跟你讲解更发杂但没什么用的WinMain函数签名。好,现在,如下修改入口:
第一章|第一章 Windows 窗口编程
文章图片
重新编译运行,你将连控制台那个黑黑的窗口都没有了,程序什么都没有显示,大概过了 3 秒便退出了。如果真的是那样,很高兴告诉你,你刚才的修改成功了,什么都不显示是正确的,因为你确实也没创建一个窗口:)。事实是,在 WINDOWS 子系统下,你必须自行创建并精心维护每一个窗口。不过在学习创建正统的窗口之前,我们先来显示一个对话框版的Hello World。现在修改 Win32GUI.cpp 为如下内容:
#include int main(int argc, char* argv[]) { MessageBoxW(nullptr, L"Hello world!", L"标题", MB_OKCANCEL); return 0; }

【第一章|第一章 Windows 窗口编程】编译运行后,你会发现程序不再很快退出了,一个很常见的对话框冒了出来。
第一章|第一章 Windows 窗口编程
文章图片
接下来,不管你点击 OK 还是 CANCEL,程序都会退出。MessageBoxW
Windows GUI 提供的对话框函数,它的工作原理和我们接下来自己创建窗口的步骤一样: 在函数内部创建了个窗口(就是这个对话框),随即它就进入事件循环,等待输入,在我们点击关闭或者那两个按钮之前,我们的程序处于睡眠状态,不过一旦它接收到相关消息(或者说事件),系统就会唤醒它,之后函数返回,再之后就是main函数返回,程序结束。
DPI 惹得祸 不知道你注意到没有,我把标题和Hello world!用红色框起来了,仔细看一下,你会发现他们的清晰度不一样,标题很清晰,但是Hello world!有点模糊,再看看下面那两个按钮也是一样:模糊,而你的运行结果未必是这样。这是因为我用的 DPI 是标准的 2 倍,系统对对话框客户区进行了缩放,客户区是在标准 DPI 上绘制的,标题清晰的原因,是因为标题和那个叉按钮属于非客户区域,系统默认对非客户区域进行了正确的 DPI 缩放。那么如何解决这个问题呢,这种模糊可不大好。其实这个问题很容易解决,从 Windows vista 开始,微软就致力于解决 DPI 相关问题,这一路上有多种方法解决这个问题。直到 Windows 10 创造版 1703,这个问题才有了比较完美的解决方案。虽然我们不涉及很多 Windows 平台提供给我们的 GUI 接口,但我觉得,在 DPI 问题上,至少我们要做到心中有数,不要把我们接下来要创建的主窗口和绘制窗口的大小搞错。
线程级别的 DPI 控制 在最新的 DPI 解决方案中,微软推荐用线程级别的 DPI 感知来处理 DPI 相关问题。什么是线程级别的 DPI 感知呢,其实这个和窗口的创建有关,因为本身 DPI 对线程并没有意义。在 Windows 里当你创建窗口的时候,你肯定要在某个线程里做,一般来说都是主线程,也就是从我们的main函数。当我们设置一个线程的 DPI 感知时,它创建窗口时都会用这个 DPI 感知类型去创建,结果就是窗口的 DPI 和所在显示器 DPI 一致,当你把窗口移动到别的显示器时,你的窗口会得到 DPI 改变通知,其他更复杂的信息在这里我就不过多赘述。下面我们来设置线程的 DPI,这个操作应该尽早做,我建议把它作为main函数的第一句话。
#include int main(int argc, char* argv[]) { SetThreadDpiAwarenessContext( DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); MessageBoxW(nullptr, L"Hello world!", L"标题", MB_OKCANCEL); return 0; }

运行结果
第一章|第一章 Windows 窗口编程
文章图片
DPI 感知 现在看来是不是清晰多了,看起来一切都是自动的,但实际上是MessageBoxW在内部做了处理,但你要通过设置 DPI 感知让它知晓。DPI 的问题在我们的课程里最重要的就是窗口大小了,设置DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2会导致我们直接面对设备像素,我们在指定窗口的大小的时候需要指定设备像素个数,这个时候你就要考虑到窗口的 DPI,并进行一些放大。否则你的窗口可能在高 DPI 显示器就显得很小。下面我们将去掉这个对话框进行真正的窗口创建。
创建窗口 在 Windows 上创建窗口,我们需要先创建一个窗口类,窗口类用来控制窗口的一些公共属性,其中有个很重要的属性是消息回调函数,他的声明如下:
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);

窗口类
WNDCLASSEXW wcex; memset(&wcex, 0, sizeof(WNDCLASSEXW)); wcex.cbSize = sizeof(WNDCLASSEXW); wcex.style = CS_DBLCLKS | CS_OWNDC; wcex.lpfnWndProc = WndProc; wcex.hInstance = GetModuleHandle(nullptr); wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); wcex.lpszClassName = L"OpenGL_GUI_Class"; // 创建窗口类,返回值存到全局变量 class_id 中 class_id = RegisterClassExW(&wcex);

注册窗口类需要一个名为WNDCLASSEXW的数据结构,这个结构里,我们需要填充一些成员然后调用RegisterClassExW。其中比较重要的成员是lpfnWndProc,它就是刚才我说的消息回调函数指针,当窗口接收到消息时,这个数据成员指向的函数会被调用。另外lpszClassName成员你可以简单的认为就是我们这个窗口类的字符串形式的 ID。RegisterClassExW成功后会返回WORD类型的窗口类 ID。
主窗口
main_window = CreateWindowW( reinterpret_cast(class_id), // 之前创建的窗口类 L"Windows 窗口编程",// 窗口标题 WS_OVERLAPPEDWINDOW,// 窗口样式 0, 0, 800, 600,// 窗口位置与尺寸 nullptr,// 父窗口 nullptr, GetModuleHandle(nullptr), nullptr);

第一个参数正是我们之前创建的窗口类,不过因为类型问题我们需要做个转型,实际上我们也可以填写之前创建窗口类时所用的lpszClassName,那样不需要做转型,但我更喜欢传递数值,那样不容易弄错。第三个窗口样式参数呢,WS_OVERLAPPEDWINDOW通常主窗口都会带有这个属性,你可以理解为这个窗口有标题栏,关闭按钮,系统菜单等等。
接下来我们显示这个窗口。
// 更改显示状态为 show ShowWindow(main_window, SW_NORMAL); // 发送第一个 WM_PAINT 消息给 WndProc 消息回调函数 UpdateWindow(main_window);

消息循环

    推荐阅读