在 WPF 中创建 OpenGL 窗口






4.86/5 (22投票s)
关于使用 Windows Presentation Foundation 创建 OpenGL 应用程序的指南。

前言
本文展示了如何在基于Windows Presentation Foundation (WPF) 的应用程序中托管OpenGL内容。在开发一个新项目时,我遇到了这个问题,并想分享我的发现。我不是WPF或OpenGL的专家。
本示例中的控件是通过托管C++实现的,因为它更容易使用本地OpenGL和Win32库。我认为这是托管C++实用性的一个很好的例子。请注意,您也可以通过相同的方式为现有的本地C++控件创建包装器。基于Tao Framework的版本使事情更加容易,不需要任何托管C++。
目录
范围与先决条件
示例应用程序将展示一个C#的WPF程序,该程序显示一个OpenGL窗口(或控件)。这与CAD/CAM应用程序中遇到的情况类似。此外,按ESC键时窗口应关闭。在WinForms和Win32中,这个问题相对容易解决。有很多优秀的文章和示例(感谢Jeff Molofee,即NeHe)。然而,由于WPF的内部结构与Win32 API或WinForms的内部结构截然不同,我们需要做一些改变。
幸运的是,有一个非常巧妙的框架叫做“Tao”,可以在 The Tao Framework 找到。Tao Framework使本文显得多余。剩下的只是一个非常简单的示例应用程序,可以下载到文章顶部,以及Tao的部分源代码。请注意,Tao是在不同的许可(MIT License)下分发的。有关详细信息,请参阅“Copying”文件。
现在,如果您真的想知道如何手动完成,那么我们开始吧:在本文中,我假设您对如何使用Win32 API创建OpenGL窗口有一个基本的了解。您之前没听说过 `PIXELFORMATDESCRIPTOR`?在这种情况下,您可能需要阅读NeHe的第一个教程。(请参阅 资源 部分)。
此外,一些非常基础的WPF知识也会很有用(例如,如何在XAML中从另一个程序集中引用自定义控件)。我建议您在此处阅读The Code Project上Sacha Barber撰写的优秀WPF入门文章。(非常感谢Sacha的文章!)
关键词
最值得注意的是,本文将使用 `WindowsFormsHost` 和 `HwndHost`。
问题
为了创建一个OpenGL窗口,我们需要有一个专用的 `HWND`。在Win32中,我们可以简单地创建自己的。另一方面,`WindowsForms` 控件本身都有自己的 `HWND`,所以我们可以直接使用它。然而,在WPF中,**应用程序**只有一个 `HWND`(有些例外:例如,菜单有自己的窗口)。由于我们不想干扰WPF控件的渲染,获取应用程序的 `HWND` 不是一个好主意(如果可能的话)。那么我们如何才能得到一个供OpenGL渲染的窗口呢?
解决方案
Microsoft为WPF/Win32互操作提供了两个简单的类。顾名思义,这些类位于 `namespace System.Windows.Interop`。这就是前面提到的 `WindowsFormsHost` 和 `HwndHost` 类。我们将使用 `WindowsFormsHost` 托管一个 `WindowsForms` `UserControl`,并使用 `HwndHost` 托管一个自定义的Win32 API窗口。请注意,`WindowsFormsHost` 实际上是从 `HwndHost` 派生的。让我们先看看使用 `WindowsFormsHost` 中的 `WindowsForms UserControl` 的简单情况。
使用WindowsFormsHost
我们可以像这样在WPF应用程序的XAML文件中嵌入 `WindowsFormsHost` 控件...
<int:WindowsFormsHost Name="windowsFormsHost1">
<oglc:OpenGLUserControl Name="openGLControl1"/>
</int:WindowsFormsHost>
(请注意,这里使用的 `namespace` 必须先声明。有关更多信息,请参阅示例代码或Sacha的文章。)
...其中 `OpenGLUserControl` 本身定义为(托管C++)
public ref class OpenGLUserControl : public UserControl
{
// ...
};
或者,在C#中,分别定义为...
public class OpenGLUserControl : UserControl
{
// ...
};
...。这还不特定于OpenGL,并且可以方便地用于托管任何Windows Forms控件!
实现Windows Forms控件
对于我们支持OpenGL的Forms控件,我们需要以下声明和成员变量
public ref class OpenGLUserControl : public UserControl
{
private:
HDC m_hDC;
HWND m_hWnd;
HGLRC m_hRC;
System::ComponentModel::Container^ components;
//...
}
如果您以前没有使用过托管C++,请忽略“^”符号。
初始化时,我们在构造函数中注册一个委托
this->Load += gcnew System::EventHandler(this,
&OpenGLUserControl::InitializeOpenGL);
在C#中,它看起来会简单一些
this.Load += new System.EventHandler(InitializeOpenGL);
初始化处理程序如下
virtual void
InitializeOpenGL( Object^ sender, EventArgs^ e)
{
// Get the HWND from the base object
m_hWnd = (HWND) this->Handle.ToPointer();
// ... ChoosePixelFormat, SetPixelFormat,
//wglCreateContext, etc.
}
我们需要在窗口大小改变时调整OpenGL视口,所以我们需要注册另一个委托
this->SizeChanged += gcnew EventHandler(this,
&OpenGLUserControl::ResizeOpenGL);
(为了避免文章臃肿,我不会每次都写出C#版本。)
此方法所做的只是设置OpenGL视口和更新投影矩阵。事实上,我选择使用正交投影,原因稍后会解释。
对于透视投影,当窗口大小改变时,投影矩阵必须重新计算,例如使用 `gluPerspective()` 或 `glFrustum()`,这就是为什么我将代码保留在此方法中的原因。
void ResizeOpenGL(Object^ sender, EventArgs^ e)
{
// ...
glViewport( 0, 0, Width, Height );
// ...
glOrtho(-1.0, 1.0, -1.0, 1.0, 1.0, 100.0);
// or gluPerspective(), glFrustum(), etc.
// for perspective projections, we need the
// aspect ratio of the window
}
此外,为了避免闪烁,我们必须重写 `OnPaintBackground()` 方法
virtual void OnPaintBackground( PaintEventArgs^ e ) override
{
// not doing anything here avoids flicker
}
实际的OpenGL绘图可以在 `OnPaint()` 方法中执行
virtual void OnPaint( System::Windows::Forms::PaintEventArgs^ e ) override
{
// Do very fancy rendering
}
基本上就是这样!我们现在有了一个可以显示OpenGL窗口的Windows Forms控件。示例代码还渲染了一个令人印象深刻的三角形!
托管Win32 API窗口
现在我们可以再向上继承一个级别,并使用 `HwndHost` 来处理,这样我们就可以使用任何Win32控件(或窗口)。首先,我们不能再在XAML中插入控件了。相反,我们在XAML中创建一个占位符,这里只是一个 `Border` 控件
<Window x:Class="WPFOpenGLApp.OpenGLHWndWindow"
Title="OpenGL Test Window" Height="300" Width="480"
Loaded="Window_Loaded">
<Grid>
<Border Name="hwndPlaceholder" />
</Grid>
</Window>
...并在加载时以编程方式将其附加到子元素上
private void Window_Loaded(object sender, RoutedEventArgs e)
{
// Create our OpenGL Hwnd 'control'...
HwndHost host = new WPFOpenGLLib.OpenGLHwnd();
// ... and attach it to the placeholder control:
hwndPlaceholder.Child = host;
}
大功告成!
实现“控件”
实现控件本身与 `WindowsForms` 情况也略有不同
- 使用 `HwndHost`,我们自己创建 `HWND`。为此,我们还需要注册我们的 `Window` 类。
- 为了绘图,我们只重写 `OnRender()` 方法。没有 `Paint`/`PaintBackground` 的区别。
- 我们必须手动处理系统的DPI设置。
- 我们的 `Window` 现在有了自己的 `WindowProc`!
原则上,您可以采用一个完整的Win32应用程序并将其放入 `HwndHost` 中。创建窗口的过程与Win32下的相同,但必须在重写的 `BuildWindowCore()` 方法中执行。
virtual HandleRef BuildWindowCore(HandleRef hwndParent) override
然而,WPF和Win32之间有意义的交互本身就充满危险。稍后将详细讨论。
允许窗口的多个实例
旁注:为了允许这样做,我们检查 `WNDCLASS` 是否已被注册
bool RegisterWindowClass()
{
//
// Create custom WNDCLASS
//
WNDCLASS wndClass;
if(GetClassInfo(m_hInstance,
m_sWindowName, &wndClass))
{
// Class is already registered!
return true;
}
// (register class) ...
}
注册窗口类
这与Win32中的情况完全一样。然而,这似乎有点奇怪:`HwndHost` 类提供了名为 `WndProc()` 的托管方法。MSDN建议重写它,但我没有设法通过这种方式初始化窗口。
在注册 `Window` 类时,可以指定要使用的 `WNDPROC`。将其留空会导致初始化过程中出现奇怪的访问冲突,而以下简单的实现已被证明可以正常工作,从而使可重写的 `WndProc()` 方法变得无关紧要。
LRESULT WINAPI
MyMsgProc(HWND _hWnd, UINT _msg,
WPARAM _wParam, LPARAM _lParam)
{
return DefWindowProc( _hWnd, _msg, _wParam, _lParam );
}
bool RegisterWindowClass()
{
WNDCLASS wndClass;
wndClass.lpfnWndProc = (WNDPROC)MyMsgProc;
// ...
}
保持焦点
然而,此时窗口没有焦点。不幸的是,这不仅会阻止我们的 `WNDPROC` 处理任何键盘事件,还会阻止 `HwndHost` 将键盘信息转发给WPF。因此,我们必须通过更复杂的 `MyMsgProc` 版本手动获取焦点。
LRESULT WINAPI
MyMsgProc(HWND _hWnd, UINT _msg,
WPARAM _wParam, LPARAM _lParam)
{
switch(_msg)
{
// Make sure the window gets focus when it has to!
case WM_IME_SETCONTEXT:
if(LOWORD(_wParam) > 0)
SetFocus(_hWnd);
return 0;
default:
return DefWindowProc( _hWnd, _msg, _wParam, _lParam );
}
}
请注意,我们必须检查 `LOWORD(_wParam) > 0`,否则该消息代表失去焦点而不是获得焦点。
使用上面提供的简单消息处理程序,大多数命令将被转发给父级。因此,我们可以轻松地在拥有宿主控件的基于WPF的 `Window` 类中捕获键盘事件。
然而,这个话题可能要复杂得多,特别是如果我们想要Win32控件和WPF之间进行双向交互。但这超出了本文的范围。
DPI感知
由于现在有越来越多的屏幕分辨率高于96 DPI的设备,应用程序具有DPI感知能力变得越来越重要。说实话,直到我将DPI设置为120之前,我从未在意过DPI。
这就是为什么我选择使用正交投影:它使我们能够(通过视觉方式)检查我们是否正确映射了屏幕。
在我们这里,这个问题变得非常烦人:如果您不考虑系统的DPI设置,您将有一个很大的边框,您根本无法绘制——您的GL窗口太小了!

为了避免这种情况,我们在初始化时获取系统的DPI设置,并在调整大小时将其乘以新的窗口大小。
virtual HandleRef
BuildWindowCore(HandleRef hwndParent) override
{
// ...
m_hDC = GetDC(m_hWnd);
// Technically, the DPI can be different for
// X and Y resolution. It is not particularly
// a lot of work to support that feature, so we do it.
m_dScaleX = GetDeviceCaps(m_hDC, LOGPIXELSX) / 96.0;
m_dScaleY = GetDeviceCaps(m_hDC, LOGPIXELSY) / 96.0;
}
virtual void
OnRenderSizeChanged(SizeChangedInfo^ sizeInfo) override
{
// ...
int iHeight = (int)
(sizeInfo->NewSize.Height * m_dScaleY);
int iWidth = (int)
(sizeInfo->NewSize.Width * m_dScaleX);
glViewport( 0, 0, iWidth, iHeight);
// ...
}
结论
尽管所介绍的技术乍一看非常相似,但它们针对的是不同的事物:`HwndHost` 是一个类,**实际控件**从中派生。另一方面,`WindowsFormsHost` 是一个我们可以在XAML文件中放置的WPF控件——在这种情况下,实际控件必须是一个 `UserControl`。
虽然 `WindowsFormsHost` 可以毫不费力地使用任意的WinForms用户控件,但使用 `HwndHost` 可能非常棘手,尤其是在输入处理方面。这在很大程度上是因为它完全破坏了GUI的控件方案,并且在输入事件的情况下,甚至会覆盖主(WPF)应用程序。另一方面,能够通过一些技巧组合Win32和WPF仍然是惊人的!
未解决的问题,待办事项
有一件事让我感到困扰的是 `CS_OWNDC` 的确切行为。我在网上读了一些关于它的文章,但最终没有找到令我满意的解释。从代码中删除它似乎并没有改变任何东西,但我很想知道当我们执行更复杂的渲染操作时会发生什么。
另一个问题是性能。我没有在文章中讨论过,这是有原因的。我的系统几乎无法以全分辨率显示支持透明度的Vista桌面……在我的情况下,性能是一场灾难!然而,我认为这在很大程度上是由于我旧的GeForce MX 5200的填充率瓶颈。此外,我们自然无法使用定时器来使控件失效来测量性能!
致谢,资源,后记
感谢阅读!
这是我的第一篇文章。呼……工作量很大!
非常感谢任何反馈!
资源
- Barber, Sacha: WPF: A Beginner's Guide - Part 1 of n
- MSDN: WPF and Win32 Interoperation Overview
- Molofee, Jeff: NeHe's OpenGL Tutorials
历史
- 2009-03-05 v. 1.2: 添加了Tao代码
- 2008-02-20 v. 1.1: 修复了一些拼写错误
- 2008-02-19 v. 1.0: 初始发布