这篇文章是关于使用MVC(Model-View-Controller,模型-视图-控制)框架在windows平台下创建OpenGL图形界面应用程序。MVC框架在GUI(Graphic User Interface,图形用户界面)应用程序中被普遍使用,并且在很多GUI库中被使用,例如.NET,MFC,Qt,Java等。MVC框架的好处是将与系统无关的OpenGL命令和Windows系统相分离,和多个窗口之间的通用的消息路由。
MVC 模式是将应用程序划分成3个独立的组件:模型层,视图层,控制层来尽可能地降低他们之间的相互依赖。
MVC设计模式
模型层是应用程序的核心部分,它包含了所有应用程序中的数据和定义应用程序行为的实现。更重要的是,模型层没有任何指向视图层和控制层的引用,这意味着模型层是完全独立的。它完全不知道视图层和控制层的行为。模型层简单地加工视图层和控制层的请求。
视图层负责渲染可视化的内容到屏幕上。视图层不含有指向控制层的引用(与控制层独立)。它只在控制层发送更新请求时执行渲染操作。然而,视图层需要有指向模型层的引用,因为它需要知道从哪里得到数据,这样才能将数据渲染到屏幕上。
控制层是用户和应用程序之间的桥梁,它接受和处理所有的用户事件,如何鼠标和键盘输入。这个模块需要知道访问那个模型层组件和视图层组件。为了处理用户事件,控制层组件请求模型层处理新的数据,同时,告诉视图层更新显示的数据。
这儿有一个非常简单的例子。假设你做了一个将加拿大元转换成美元的工具。当用户点击“Convert”按钮时,你的应用程序应该怎么做?
一个GUI例子:货币兑换
- 控制层组件首先接受按钮点击事件
- 控制层组件将输入的数据发送到模型层并请求转换
- 模型层将输入数据转换成美元并保存结果
- 控制层逐渐请求视图层组件显示结果
- 视图层组件从模型层接收数据
- 视图层组件在屏幕上显示结果
MVC设计模式的主要的优点是清晰和模块化。由于MVC模式清晰地将一个应用程序划分成了3个逻辑上的组件,它变得更干净和更容易理解每个组件的任务,并且每个模块能单独地被多个开发着维护。由于高效的模块化,组件更加易于扩展和集成。例如:你可以自定义一个视图显示界面而不需要修改模型层和控制层,或者同时添加多个视图。
这是一个简单的OpenGL Windows应用程序。它除了鼠标交互之外不含有其他GUI控制事件。然而,这个例子很容易理解如何利用MVC模型实现一个OpenGL应用程序。我们将在下面讨论更复杂的情况。
这个应用程序有3个独立的C++类,ModelGL,ViewGL和ControllerGL。对OpenGL应用程序来说,所有与系统无关的OpenGL命令被放在ModelGL组件中,这样模型层组件可以被多个平台平台重复使用而不需要做任何更改。因此,模型层和视图层和控制层完全独立。
视图层组件用来将需要显示的数据渲染到屏幕中。因此,所有显示设备属性(渲染上下文,颜色位,等)在这个组件内。与系统相关的OpenGL命令也放在这个组件中,例如wglCreateContext()和wglMakeCurrent()。视图层组件不含有指向控制层的引用(与控制层独立),但是可能需要引用模型层组件,例如:得到模型层的数据来更新视图层的内容。
控制层组件首先接收用户事件,然后更新模型层的状态,通知视图层逐渐渲染场景。它有基本的输入处理函数来处理键盘和鼠标事件。请仔细查看ControllerGL类中的代码:keyDown(),IButtonDown(),rButtonDown(),mouseMove()等。ControllerGL类从Controller类派生过来。你可以简单地添加事件处理到ControllerGL类中如果你需要重写默认的行为。
这3个对象都是在main函数中创建的,然后一个指向ControllerGL对象的窗口被创建。我使用了一个帮助类WIndow.cpp类来创建一个窗口。注意main函数依然非常简单,所有详细的实现细节都被移到这3个单独的组件中了:ModelGL,ViewGL和ControllerGL。
int WINAPI WinMain(...)
{
// 实例化 Model and View,这样Controller能引用它们
ModelGL model;
Win::ViewGL view; //在"Win"命名空间中因为它于窗口相关
//使用指向ModelGL和ViewGL的指针创建ControllerGL对象
Win::ControllerGL glCtrl(&model, &view);
// 使用Controller对象创建窗口
Win::Window glWin(hInst, L"glWinSimple", 0, &glCtrl);
glWin.setWindowStyle(WS_OVERLAPPEDWINDOW | WS_VISIBLE |
WS_CLIPSIBLINGS | WS_CLIPCHILDREN);
glWin.setClassStyle(CS_OWNDC);
glWin.setWidth(400);
glWin.setHeight(300);
glWin.create();
glWin.show();
// 进入消息循环 //
int exitCode;
exitCode = mainMessageLoop();
return exitCode;
}
创建OpenGL窗口和创建其他普通的窗口除了OpenGL渲染上下文(rendering context,RC)之外其他都是一样的。OpenGL渲染上下文是连接OpenGL和Windows系统的端口。所有OpenGL命令可以通过这个渲染上下文传送。渲染上下文必须和一个和它拥有同样像素格式的设备上下文(device context,DC)相关联。因此,OpenGL可以在设备表面绘制图形。
在你的WM_CREATE消息处理函数中,你可以创建一个RC:
- 使用GetDC()函数得到OpenGL窗口的DC和窗口句柄
- 使用SetPixelFormat()函数和DC设置希望的像素格式
- 使用wglCreateContext()函数和DC创建RC
- 使用ReleaseDC()释放DC
在你的WM_CLOSE消息处理函数中,你可以删除RC:
- 使用wglMakeCurrent()函数和NULL参数释放RC
- 使用wglDeleteContext()函数删除RC
在你的渲染循环中,在调用任何的OpenGL命令之前使用wglMakeCurrent()函数设置渲染上下文为当前的RC。我为渲染循环使用了一个单独的线程。请看下面的部分:“渲染OpenGL的单独线程”。
查找希望的像素格式可以通过使用DescribelPixFormat()函数来查找所有的像素格式。一个标准的查找最好的像素格式的机制的方法findPixelFormat()定义在ViewGL类中。
// 查找最好的像素格式
int findPixelFormat(HDC hdc, int colorBits, int depthBits, int stencilBits)
{
int currMode; // 格式模式的ID
int bestMode; // 返回值,最好的像素格式
int currScore; // 现在像素格式的分数
int bestScore; // 做好的像素格式的分数
PIXELFORMATDESCRIPTOR pfd;
// 查找所有可能的像素格式
bestMode = 0;
bestScore = 0;
for(currMode = 1;
::DescribePixelFormat(hdc, currMode, sizeof(pfd), &pfd) > 0;
++currMode)
{
// 如果不支持opengl则忽略
if(!(pfd.dwFlags & PFD_SUPPORT_OPENGL))
continue;
// 如果不能被渲染到窗口则忽略
if(!(pfd.dwFlags & PFD_DRAW_TO_WINDOW))
continue;
// 如果不支持rgb模式则忽略
if((pfd.iPixelType != PFD_TYPE_RGBA) ||
(pfd.dwFlags & PFD_NEED_PALETTE))
continue;
// 如果不是双缓冲区则忽略
if(!(pfd.dwFlags & PFD_DOUBLEBUFFER))
continue;
// 尝试寻找最好的像素格式
currScore = 0;
// 颜色位
if(pfd.cColorBits >= colorBits) ++currScore;
if(pfd.cColorBits == colorBits) ++currScore;
// 深度位
if(pfd.cDepthBits >= depthBits) ++currScore;
if(pfd.cDepthBits == depthBits) ++currScore;
// 模板位
if(pfd.cStencilBits >= stencilBits) ++currScore;
if(pfd.cStencilBits == stencilBits) ++currScore;
// alpha位
if(pfd.cAlphaBits > 0) ++currScore;
// 判断是否是到目前为止最好的模式
if(currScore > bestScore)
{
bestScore = currScore;
bestMode = currMode;
}
}
return bestMode;
}
MS是一个事件驱动的系统。一个事件驱动的windows应用程序如果没有事件触发的话将不会做任何事情。然而,OpenGL渲染窗口即使没有事件触发也需要经常的更新。一个解决OpenGL窗口经常更新的方法是在消息循环中使用PeekMessage()。
int mainMessageLoop()
{
MSG msg;
while(1)
{
if(::PeekMessage(&msg, 0, 0, 0, PM_REMOVE))
{
if(msg.message == WM_QUIT)
{
break;
}
else
{
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
}
else
{
// 它不应该在这儿,因为这个消息循环只是被用来处理窗口消息
render();
}
}
return (int)msg.wParam;
}
然而,为渲染OpenGL使用一个单独的线程是一个更好的方法。多线程的好处是你可以离开消息循环去做其他的事情(只处理windows事件),单独的线程将会独立地处理渲染OpenGL场景。我们也不会再消息循环中暴漏任何OpenGL渲染的方法。这样,消息循环可以和以前一样简单。
int mainMessageLoop()
{
MSG msg;
// 循环一直到接收WM_QUIT(0)消息
while(::GetMessage(&msg, 0, 0, 0) > 0)
{
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
return (int)msg.wParam;
}
当一个窗口创建时(WM_CREATE事件触发),ControllerGL对象将会初始化ModelGL和ViewGL对象,然后,为OpenGL渲染开启一个新的线程。_beginthreadex()被用来创建一个新的线程。请查看ControllerGL::create()和ControllerGL::runThread()获取更多的细节。
// handle WM_CREATE
int ControllerGL::create()
{
....
// 为渲染OpenGL创建一个新的线程
threadHandle = (HANDLE)_beginthreadex(
0, 0,
(unsigned (__stdcall *)(void *))threadFunction,
this, 0, &threadId);
return 0;
}
// 启动新线程
void ControllerGL::threadFunction(void* param)
{
((ControllerGL*)param)->runThread();
}
// 渲染循环
void ControllerGL::runThread()
{
// 在这个线程中设置当前的RC
::wglMakeCurrent(viewGL->getDC(), viewGL->getRC());
// 初始化 OpenGL 状态
modelGL->init();
// 渲染循环
while(loopFlag)
{
::Sleep(10); // 让步给其它进程和线程
modelGL->draw();
viewGL->swapBuffers();
}
// 终止渲染线程
::wglMakeCurrent(0, 0); // 重置RC
::CloseHandle(threadHandle);
}
例子2:glWin
这个例子有3个窗口,OpenGL渲染子窗口,包含所有控件的子窗口,以及容纳这两个窗口的主窗口。(在主窗口中有菜单栏和状态栏,但是在这篇文章中我们不讨论它们)
下载源文件和可执行文件: glWin.zip
由于有3个窗口,需要3个Controller对象,每个窗口一个Controller对象:ControllerMain,ControllerGL,ControllerFormGL。两个View控件,ViewGL和ViewFormGL,一个用来渲染OpenGL,一个用来显示控件。因为主窗口只是一个简单的容器,它没有View控件。注意只需要一个Model对象,它被3个Controller和2个View引用。
glWin的MVC图
ViewFormGL是一个包含控件(按钮,文本框等)的对话框窗口。因此,所有的控件被定义在ViewFormGL类中。我为经常使用的控件实现了对应的类,它们在"Controlls.h"中声明。现在使用的控件有Button,RadioButton,CheckBox,TextBox,EditBox,ListBox,TrackBar,Combox和TreeView。
当用户点击”Animate“按钮时会发生下面的事情:
- ControllerFormGL首先接收到BN_CLICKED事件
- ControllerFormGL在ModelGL中设置动画标志为真,这样地球会旋转
- ControllerFormGL通知ViewFormGL改变按钮的文字为”Stop“
一个windows应用程序在你创建窗口(更加正确的说法是使用RegisterClass()或RegisterClassEX()注册窗口)的时候需要提供一个回调函数(窗口过程)。当某件事情在你的程序中发生,例如,用户点击了按钮,windows系统向你的应用程序发送消息,消息会被传送到你所指定的窗口过程中。
一般情况下,窗口过程如下:
LRESULT CALLBACK wndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch(msg)
{
case WM_COMMAND:
...
break;
case WM_CREATE:
...
break;
// 更多的消息
...
default:
return ::DefWindowProc(hWnd, msg, wParam, lParam);
}
return 0;
}
MVC框架的另外一个好处是动态消息路由,就是将消息分发给与窗口句柄相关的Controller。因此我们可以创建一个窗口过程并在你创建的所有窗口中使用它。基本的思想是当你创建一个窗口时将指向Controller对象的指针放在CreateWindow()或CreateWindowEx()的lpParam参数中。当WM_NCCREATE消息触发时,我们从lpCreateParams参数中提取出这个指针并且将它存储为窗口GWL_USERDATA属性值。然后,另一个消息触发时,我们简单地查找窗口的GWL_USERDATA值来查看那个Controller与窗口相关联。由于这个窗口过程被所有你创建的窗口使用,你不需要再重新编写其他的窗口过程。
实际的消息路由如下:
LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
LRESULT returnValue = 0; // 返回值
// find controller associated with window handle
static Win::Controller *ctrl;
ctrl = (Controller*)::GetWindowLongPtr(hwnd, GWL_USERDATA);
if(msg == WM_NCCREATE) // Non-Client Create
{
// WM_NCCREATE 消息在非客户端的部分创建之前被触发(边框,
// 标题栏, 菜单,等) 。 这个消息的lParam参数中有一个指向
// CREATESTRUCT结构体的指针。CREATESTRUCT结构体中的lpCreateParams
// 实际包含CreateWindowEX()中的lpPraram参数。
// 首先,检索在Win::Window中设置的指向Controller的指针
ctrl = (Controller*)(((CREATESTRUCT*)lParam)->lpCreateParams);
ctrl->setHandle(hwnd);
// 第二步,保存指向Controller的指针到GWL_USERDATA中,
// 这样,其他消息可以被这个相关联的Controller路由
::SetWindowLongPtr(hwnd, GWL_USERDATA, (LONG_PTR)ctrl);
return ::DefWindowProc(hwnd, msg, wParam, lParam);
}
// 检查空指针,因为GWL_USERDATA初始化为0,
// 然后我们在 WM_NCCREATE消息被触发时储存了一个有效的指针
if(!ctrl)
return ::DefWindowProc(hwnd, msg, wParam, lParam);
// 消息路由到指定的Controller中
switch(msg)
{
case WM_CREATE:
returnValue = ctrl->create();
break;
// 更多的消息
...
default:
returnValue = ::DefWindowProc(hwnd, msg, wParam, lParam);
}
return returnValue;
}
在main函数中向下面的代码一样创建3个窗口。注意当我们在初始化窗口时提供了指向Controller对象的指针。这个指针的值必须保存在在每个窗口的GWL_USERDATA中,然后消息路由将会动态地将窗口事件分发到每个Controller中。
// 创建主窗口
Win::ControllerMain mainCtrl;
Win::Window mainWin(hInst, "glWinApp", 0, &mainCtrl);
mainWin.create();
// 创建OpenGL渲染窗口
Win::ControllerGL glCtrl(&modelGL, &viewGL);
Win::Window glWin(hInst, L"WindowGL", mainWin.getHandle(), &glCtrl);
glWin.create();
// 创建包含控件的窗口
Win::ControllerFormGL formCtrl(&modelGL, &viewFormGL);
Win::DialogWindow glDialog(hInst, IDD_FORMVIEW, mainWin.getHandle(), &formCtrl);
glDialog.create();
创建包含控件的窗口时有一点点的不同。我们在WM_INITDIALOG消息触发时就将Controller的指针保存在GWL_USERDATA中了(而不是在WM_NCCREATE消息触发时)。查看procedure.cpp获取更多的细节。
这个MVC框架提供了一个基本的Controller类,它是默认的时间处理器。事实上,它不做任何事情并返回0.当一个消息触发时如果想做一个有意义的事情,你需要创建一个继承自Controller类的类,并重写它的虚方法。虚方法的名字和去除前缀的消息的名字一样。例如,IButtonDown()函数是处理WM_LBUTTONDOWN消息的。
对于上面的glWin例子,ControllerMain,ControllerGL,ControllerFormGL都派生自Controller类。
ControllerMain负责当用户点击关闭按钮或”Exit“按钮时处理WM_CLOSE消息来终止应用程序。
ControllerGL简单处理相机操作器的鼠标交互(缩放和旋转相机),和当WM_CREATE消息到来时为OpenGL渲染创建一个单独的线程。
ControllerFormGL管理所有的控件界面。