目录
介绍
本文使用OpenTK作为.NET库,用于访问.NET上的OpenGL。该库还具有GLWpfControl,这是一个用于与WPF应用集成的控件。还有其他库可能会也可能不会为WPF集成提供选项,但描述使用OpenTK与其他库或使用WPF、OpenGL等的基本原理不在本文的讨论范围内——这些是相当公理的:“给定WPF、OpenGL和OpenTK,那么......”
唯一需要提到的是,不幸的是,GLWpfControl没有产生我预期的性能,至少对于我的实际应用程序来说不是(见文章末尾)。事实上,这甚至比仅使用具有每帧呈现的 WPF 自定义动画还要糟糕。因此,在WPF中直接使用OpenTK的GameWindow并使其表现得像一个控件的挑战。为此,以下是窗口的一组要求或验收标准:
- 窗口必须没有边框,没有标题栏。
- 窗户必须粘在主窗上,即它不能在主窗后面,其他窗户不能在主窗之间。
- 窗口必须保持“粘”在主窗口内指定的区域,并且在移动或调整大小时必须遵循主窗口。
- 窗口必须约束(剪裁)在主窗口的边框内——它不能超出这些边框(例如,移动/缩放)。
- 窗口不得捕获键盘焦点。
- 窗口必须对鼠标输入透明,即鼠标输入必须传递到下面的WPF内容。
- 可选:窗口背景必须是透明的。
- 窗口必须允许由应用程序控制,用于定位、调整大小、渲染。
现在,让我们列出此解决方案与使用专用控件(如GLWpfControl)的优缺点。
优势
- 由于与WPF的互操作性,性能不会下降。虽然我不是这方面的专家,但从我收集到的信息来看,所有这些互操作 WPF 控件(以及其他控件,如SharpGL的OpenGLControl或MonoGame的MonogameContentControl)都有一个共同点:它们使用DirectX互操作,并通过将绘图结果复制到WPF可通过其D3DImage类使用的缓冲区来工作。有额外的缓冲区复制、刷新操作,肯定会对性能产生一些影响......这不适用于直接使用GameWindow。
- OpenTK的GameWindow不需要任何OpenGL扩展,它只需要核心实现。与此相反,GLWpfControl需要NV_DX_interop扩展,具有讽刺意味的是,我的笔记本电脑的NVIDIA GPU不支持该扩展,但集成的Intel显卡支持该扩展!因此,如果您没有扩展名,那么您就不走运了。
劣势
- 虽然控件集成在WPF布局中,具有边距、水平/垂直对齐方式等,但GameWindow不是该布局的一部分,因此必须通过代码手动管理控件理所当然的定位。但这并不是那么糟糕,我们将进一步看到。
- 你不能在GameWindow之上的WPF中渲染任何东西,例如覆盖层。任何与窗口占用相同空间的渲染都将位于其下方。我不认为这个用例很频繁,我宁愿认为透明的窗口背景是一个更频繁的用例。也就是说,窗口是叠加层。本文将讨论透明度问题。
从这里开始,讨论将集中在压缩源代码中包含的应用程序(顶部链接)。应用程序的基本外观和行为也显示在顶部的动画图片中。
剖析代码
应用程序代码的结构
源代码采用Visual Studio 2022解决方案的形式,其中包含面向.NET 6的WPF应用程序项目。该OpenTK库作为NuGet包从nuget.org安装,版本设置为 4.8.2,这是撰写本文时的最新稳定版本。
该代码基于GitHub上托管的Hello Triangle OpenTK教程。本文的相关类是MainWindow和GLWindow。我将GLWindow实现拆分为两个源文件。并不是说我赞同这种做法,但这样做是为了帮助专注于本文的重要内容。文件GLWindow.article.cs包含本文的相关部分——用于创建和管理OpenGL窗口的逻辑。文件GLWindow.tutorial.cs以及着色器文件涵盖了我不会坚持的特定于OpenGL的代码,因为这直接来自提到的OpenTK教程,并且最好由上述OpenTK教程涵盖。还有一个小的static类Interop,它作为一些必需的Win32函数的PInvoke包装器。
GLWindow构造函数
GLWindow是OpenTK的GameWindow子类,负责OpenGL渲染。在介绍中,我们列出了这个窗口必须满足的基本要求,所以让我们看看这些要求是如何在代码中实现的。我们从构造函数开始:
private GLWindow(IntPtr hWndParent, Vector2i location, Vector2i size)
: base(GameWindowSettings.Default,
new NativeWindowSettings {
Location = location,
ClientSize = size,
WindowBorder = WindowBorder.Hidden
})
{
unsafe {
GLFW.HideWindow(WindowPtr);
IntPtr ptr = GLFW.GetWin32Window(WindowPtr);
uint childStyle = Interop.GetWindowLong(ptr, Interop.GWL_STYLE);
childStyle |= Interop.WS_CHILD;
childStyle &= ~Interop.WS_POPUP;
_ = Interop.SetWindowLong(ptr, Interop.GWL_STYLE, childStyle);
_ = Interop.SetWindowLong(ptr, Interop.GWL_EXSTYLE,
Interop.WS_EX_TOOLWINDOW | Interop.WS_EX_LAYERED | Interop.WS_EX_TRANSPARENT);
_ = Interop.SetParent(ptr, hWndParent);
_ = Interop.EnableWindow(ptr, false);
_ = Interop.SetLayeredWindowAttributes(ptr, 0x00000000, 0, Interop.LWA_COLORKEY);
GLFW.ShowWindow(WindowPtr);
}
_xpos = location.X;
_ypos = location.Y;
}
构造函数调用基类版本,通过NativeWindowSettings传入一些设置:从主窗口接收到的窗口的初始位置和大小,以及一个非常有用的WindowBorder.Hidden设置,使窗口没有边框和标题栏(要求之一)。我们没有为要使用的OpenGL版本指定任何设置,而是让它成为默认的 3.3 版本。
在输入构造函数主体时,基本构造函数已经创建了窗口。OpenTK为我们提供了指向窗口WindowPtr的低级不安全指针和可以使用的低级GLFW API(OpenGL glfw3.dll 的包装器)。在更改窗口之前,我们使用GLFW.HideWindow隐藏窗口,并且GLFW.GetWin32Window将Win32 HANDLE检索到窗口,我们需要进一步。因此,在构造函数主体中,我们开始进行更改以实现我们的要求,这要求将窗口设置为主窗口的子窗口。OpenTK没有给我们任何选项,因此我们需要通过PInvoke包装器Interop使用Win32 API函数SetParent。但是,根据MSDN的说法,我们需要在调用此函数之前清除WS_POPUP样式并设置WS_CHILD样式。为此,我们调用GetWindowLong以获取当前样式,然后调用SetWindowLong以相应地更改样式。我们需要通过一些扩展的样式标志来进一步改变样式,以满足更多的要求。我们通过对标志组合的第二次调用SetWindowLong来做到这一点:
- WS_EX_TOOLWINDOW防止它出现在任务栏中
- WS_EX_LAYERED|WS_EX_TRANSPARENT与SetLayeredWindowAttributes结合使用以实现透明度
接下来的调用如下:
- EnableWindow,禁用鼠标和键盘输入到窗口。鼠标事件将传递到下面的WPF窗口,键盘事件也将定向到父WPF窗口。
- SetLayeredWindowAttributes设置窗口的透明度。在这种情况下,作为0x00bbggrr值传递的颜色键(LWA_COLORKEY)将黑色(0x00000000)设置为透明颜色。这与OpenGL渲染中用于背景的颜色相匹配,这意味着窗口背景是透明的。所有其他渲染的颜色都是不透明的,这正是我们想要的。
最后,我们通过调用GLFW.ShowWindow来显示窗口,并记录窗口的初始位置,以便在必要时进行更新。
构造函数到此结束;还有更多关于SetLayeredWindowAttributes要评论的内容,以及OpenTK对透明背景、窗口不透明度和鼠标直通的支持,但我将在最后的“结论”部分发表这些评论。
GLWindow线程
此时,我们有一个子窗口,该子窗口受限制在其父窗口内,无边框,禁用鼠标和键盘输入,并且具有透明背景。我们非常接近满足介绍中的要求。
GLWindow具有从其父类GameWindow——Run方法——继承的呈现循环,这使得其调用线程繁忙。这意味着线程不能与主线程(即 WPF UI 线程)相同。因此,因此,窗口被创建并在从线程池中获取后台线程的Task中运行。为了能够做到这一点,我们需要关闭OpenTK: GLFWProvider.CheckForMainThread = false中的标志(属性),否则,在创建窗口时我们会得到一个异常。我们在创建和运行任务的static方法中执行此操作:
public static GLWindow? CreateAndRun(IntPtr hWndParent, Rect bounds)
{
GLFWProvider.CheckForMainThread = false;
using var mres = new ManualResetEventSlim(false);
GLWindow? glWnd = null;
_GLTask = Task.Run(() => {
glWnd = new GLWindow(hWndParent, ((int)bounds.X, (int)bounds.Y),
((int)bounds.Width, (int)bounds.Height));
mres.Set();
using (glWnd) {
glWnd?.Run();
}
});
mres.Wait();
return glWnd;
}
CreateAndRun旨在从主线程调用,并通过调用其Task.Run方法启动存储在_GLTask static成员中的任务。主线程传递包装在父窗口IntPtr中SetParent所需的Win32 HANDLE和子窗口的初始边界。该任务只需通过其构造函数创建子窗口,然后启动其呈现循环,这是继承的Run方法。从那时起,呈现循环将持续运行,直到关闭子窗口时应用程序结束时才会返回。
主线程和任务之间有一点同步:在并行任务完成子窗口的创建之前,主线程不会返回。这是通过ManualResetEventSlim完成的,主线程在启动任务后等待发出信号,任务在创建子窗口后发出信号。
与GLWindow通信
为了完成这些要求,我们需要能够控制子窗口的重新定位和调整大小。作为奖励,隐藏和显示也是如此。控制渲染内容和渲染时间也是很常见的。此应用程序遵循OpenTK教程,其中需要渲染的所有内容(所有顶点缓冲区)都是在窗口加载时(OnLoad) 确定的,但更现实的是,它应该比这更流畅,并且在应用程序的控制之下。
这意味着我们需要与子窗口进行通信——设置它的位置和大小,调用显示和隐藏的方法,设置影响渲染循环中渲染的参数。因为这是一个创建窗口并在不同的线程中运行其渲染循环,所以线程安全成为焦点。根据 OpenTK的FAQ部分,虽然OpenTK是线程安全的,但OpenGL本身不是。很难从中判断出究竟什么是线程安全的,什么是不安全的。移动和隐藏窗口可能是可以的,因为它们是操作系统级别的操作,不涉及OpenGL。但是,为了安全起见,我们不自找麻烦,我们遵循一般规则,即我们采取的任何影响窗口及其渲染的操作都必须是线程安全操作(甚至移动和隐藏)。
我们采用的模式是一种松散的命令模式,其中“命令”通过线程安全ConcurrentQueue对象从主线程发送到子窗口的线程。GLWindow为此类命令提供了一堆public方法,主线程可以直接调用这些方法,但这些方法只将命令排入队列(命令是Action)。以下是这些方法:
public void Cleanup()
{
EnqueueCommand(() => {
Close();
});
_GLTask?.Wait();
}
public void SetBoundingBox(Rect bounds)
{
EnqueueCommand(() => {
ClientLocation = ((int)bounds.X, (int)bounds.Y);
ClientSize = ((int)bounds.Width, (int)bounds.Height);
_xpos = bounds.X; _ypos = bounds.Y;
});
}
public void MoveBy(int deltaX, int deltaY)
{
EnqueueCommand(() => {
_xpos += deltaX; _ypos += deltaY;
ClientLocation = ((int)_xpos, (int)_ypos);
});
}
public void Show()
{
EnqueueCommand(() => {
unsafe {
GLFW.ShowWindow(WindowPtr);
}
});
}
public void Hide()
{
EnqueueCommand(() => {
unsafe {
GLFW.HideWindow(WindowPtr);
}
});
}
public void ToggleSpin()
{
EnqueueCommand(() => {
_isSpinStopped = !_isSpinStopped;
});
}
private void EnqueueCommand(Action command) =>
_commands.Enqueue(command);
我不会坚持这些方法,因为它们应该是不言自明的。值得一提的是,Cleanup是在主WPF窗口关闭时调用的方法。调用Close结束渲染循环,因此任务也结束。主线程等待任务结束,确保子窗口关闭(以及与之关联的清理)完成。清理代码位于GLWindow.tutorial.cs的覆盖OnUnload中。在我们的例子中,这是可选的,因为当应用程序退出时,进程所持有的资源(例如顶点缓冲区)无论如何都会由操作系统释放;不过,在执行特定清理的地方进行有纪律的关闭是件好事。
此外,ToggleSpin是控制渲染的简单示例。在这里,它只是停止或重新启动三角形的旋转,可以想象这在现实生活中的应用程序中被扩展为更高级的东西,例如控制要渲染的对象。
GLWindow是存储在_commands成员中的ConcurrentQueue消费者。但问题是,在什么地方使用这个队列?OpenTK的教程显示,覆盖OnUpdateFrame是处理键盘和鼠标输入的地方。在我们的例子中,窗口不处理任何键盘或鼠标输入,因此我们用“命令输入”的处理替换该处理。由于OnUpdateFrame是由渲染循环Run调用的(就像OnRenderFrame一样),那么队列中的任何Action内容都会在子窗口的线程上执行,这就是我们想要的。以下是相关方法:
protected override void OnUpdateFrame(FrameEventArgs args)
{
base.OnUpdateFrame(args);
while (TryDequeueCommand(out Action? command)) {
command();
}
}
private bool TryDequeueCommand([MaybeNullWhen(returnValue: false)] out Action command) =>
_commands.TryDequeue(out command);
此时,子窗口满足简介中的所有要求。接下来是查看WPF主窗口,该窗口揭示了GLWindow如何使用。
主窗口
<Window x:Class="WpfOpenTK.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfOpenTK"
mc:Ignorable="d"
Title="OpenTK in WPF" Height="600" Width="650"
Background="#000020"
Loaded="Window_Loaded"
Closing="Window_Closing"
KeyDown="Window_KeyDown"
>
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Rectangle Name="RenderArea" Grid.Row="1"
Grid.Column="1" Stroke="#008800"
SizeChanged="RenderArea_SizeChanged"/>
<StackPanel Grid.Row="1" Grid.Column="2"
HorizontalAlignment="Left" VerticalAlignment="Center">
<ToggleButton Width="100" Checked="HideShowButton_Checked"
Unchecked="HideShowButton_Unchecked">
<ToggleButton.Style>
<Style TargetType="ToggleButton">
<Setter Property="Content" Value="Hide"/>
<Style.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter Property="Content" Value="Show"/>
</Trigger>
</Style.Triggers>
</Style>
</ToggleButton.Style>
</ToggleButton>
<ToggleButton Width="100" Margin="0,5,0,0" Click="StopStartButton_Click">
<ToggleButton.Style>
<Style TargetType="ToggleButton">
<Setter Property="Content" Value="Start spin"/>
<Style.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter Property="Content" Value="Stop spin"/>
</Trigger>
</Style.Triggers>
</Style>
</ToggleButton.Style>
</ToggleButton>
</StackPanel>
</Grid>
</Window>
主WPF窗口由3x3网格分隔;中间的单元格是指定用于OpenGL渲染的区域。在该单元格中,我们放置了一个Rectangle除了定义GLWindow的边界框之外,没有其他作用,OpenGL渲染发生在其中。此边界框相对于主窗口。随着主窗口大小的调整,矩形的位置和大小也会改变,GLWindow也是如此。这种布局选择是完全任意的,现实生活中的应用程序将有自己的布局,其渲染区域由业务需求决定。
主窗口中的其他元素是两个按钮,用于向其GLWindow发送一些命令——一个用于隐藏和显示窗口,另一个用于停止和恢复渲染三角形的旋转。主窗口还定义了用于加载、关闭和一些键盘输入的事件处理程序。
在查看背后的代码时,我们将依次解决以下功能:
- 创建和关闭子窗口
- 调整主窗口和子窗口的大小
- 通过使用箭头键移动子窗口来平移三角形
- 使用按钮隐藏/显示和控制三角形的旋转
创建和关闭子窗口
private void Window_Loaded(object sender, RoutedEventArgs e)
{
IntPtr hWndParent = new WindowInteropHelper(this).Handle;
_glWnd = GLWindow.CreateAndRun(hWndParent, GetRenderAreaBounds());
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
_glWnd?.Cleanup();
}
创建子项是在父级的Loaded事件处理程序中完成的。在这里,我们将Win32 HANDLE获取到父级,并调用CreateAndRun前面讨论的static方法。该GetRenderAreaBounds方法将在下一段中讨论。关闭子项是在父级的Closing事件处理程序中完成的,该处理程序只是调用子级的Cleanup方法。请记住,此方法将用于关闭子项的命令排入队列,然后等待子项的线程确认它即将结束。
调整主窗口和子窗口的大小
当主窗口调整大小时,网格单元格也会调整大小,因此我们需要跟踪中间单元格中矩形的位置,以便更新子窗口的位置和大小。为此,我们处理矩形的SizeChanged事件:
private void RenderArea_SizeChanged(object sender, SizeChangedEventArgs e)
{
_glWnd?.SetBoundingBox(GetRenderAreaBounds());
}
private Rect GetRenderAreaBounds()
{
Point location = RenderArea.TransformToAncestor(this).Transform(new Point(0, 0));
return new Rect {
X = location.X,
Y = location.Y,
Width = RenderArea.ActualWidth,
Height = RenderArea.ActualHeight
};
}
重要的方法是GetRenderAreaBounds,其中矩形的位置(左上角)相对于主窗口(this)被重新获取。此方法还用于在创建子窗口时获取初始位置。为了调整大小,事件处理程序调用我们之前已经看到的子窗口SetBoundingBox,并将最终更新子窗口的ClientLocation和ClientSize属性的命令排入队列。
通过使用箭头键移动子窗口来平移三角形
主窗口的KeyDown事件处理程序捕获箭头键的按下,并沿相应的方向移动子窗口。如果三角形到达主窗口的边界,它将被剪裁并完全消失。此外,如果它与两个按钮重叠,则即使鼠标位于三角形的正上方,按钮仍会对鼠标悬停和鼠标单击做出反应。所有这些都可以通过我们创建子窗口并赋予其鼠标透明度的方式实现。
private void Window_KeyDown(object sender, KeyEventArgs e)
{
int moveStep = 5;
switch (e.Key)
{
case Key.Up:
_glWnd?.MoveBy(0, -moveStep);
break;
case Key.Down:
_glWnd?.MoveBy(0, moveStep);
break;
case Key.Left:
_glWnd?.MoveBy(-moveStep, 0);
break;
case Key.Right:
_glWnd?.MoveBy(moveStep, 0);
break;
}
}
正如我们所看到的,该MoveBy方法为子窗口排队一个命令,该命令最终会更新其ClientLocation和ClientSize。另请注意,尽管箭头键将三角形移离中心单元格,但一旦调整主窗口大小,三角形就会弹回中心单元格!
注意:通过移动窗口进行平移只是为了显示:
- 我们可以将这样的命令发送到窗口
- 主窗口(父窗口)仍接收键盘输入
- 当窗口到达父窗口的边界时,窗口会被其父窗口剪裁
- 我们有鼠标透明度,我们可以在窗口覆盖按钮时单击它们
通过将平移矩阵应用于渲染对象,可以更好地实现真正的(2D)平移要求!
使用按钮隐藏/显示和控制三角形的旋转
两个按钮的事件处理程序向子窗口发送命令,用于隐藏/显示和停止/恢复三角形的旋转:
private void HideShowButton_Checked(object sender, RoutedEventArgs e)
{
_glWnd?.Hide();
}
private void HideShowButton_Unchecked(object sender, RoutedEventArgs e)
{
_glWnd?.Show();
}
private void StopStartButton_Click(object sender, RoutedEventArgs e)
{
_glWnd?.ToggleSpin();
}
隐藏和显示子窗口可能是也可能不是有用的功能,具体取决于用例。控制渲染参数,就行使用ToggleSpin一样,绝对是有用的。
结论和兴趣点
我已经演示了如何通过在WPF窗口中嵌入一个普通的OpenTK窗口来利用WPF窗口中的OpenGL呈现,就好像它是一个控件一样。这不需要任何DX互操作,这会增加间接层并影响(在我的情况下,会破坏)性能。下载压缩的源代码,生成VS解决方案,然后亲自试用。
关于与本文相关的一些主题,还有一些想法,下面列出了这些想法作为其他兴趣点。
- 在单独的线程中创建和运行一个GameWindow是OpenTK最近才打开大门的事情,因为它不是跨平台的——在他们的GitHub存储库上有一个有趣的讨论。另一方面,这是关于WPF的,因此,我们只针对Windows。
- 我不确定的一件事是,如果同时创建并共存多个OpenGL渲染区域(每个区域都有自己的窗口)会发生什么。就此而言,GLWfpControl它本身似乎在多个实例中遇到了一些挑战。这些场景我不太感兴趣,对我来说,它们是不太常见的用例。
- 您可能已经注意到,每当GameWinodw使用的不安全指针WindowPtr时,代码unsafe块都会出现。编译器需要/unsafe编译器参数来编译项目,这可以通过项目设置指定。如果您对此感到不舒服,可以将与OpenGL相关的代码隔离到单独的库中,而不会影响WPF应用程序项目的其余部分。
- 在源代码中没有太多的防御性编程,例如错误检查,因为这是一个演示应用程序,而不是现实生活中的应用程序。在现实生活中的应用程序中,我们至少应该检查该系统是否支持我们想要使用的OpenGL版本。这需要将窗口创建调用放在一个try...catc块中: h
try {
glWnd = new GLWindow(hWndParent, bounds);
}
catch (GLFWException ex) when (ex.ErrorCode == ErrorCode.VersionUnavailable) {
// Error - OpenGL version not supported
}
- 关于透明度问题,GLFW文档指出GLFW支持帧缓冲透明度(可用于制作透明/tanslucid背景)以及整个窗口透明度。OpenTK也为.NET带来了这种支持:可以通过调用来启用帧缓冲透明度,例如
GLFW.WindowHint(WindowHintBool.TransparentFramebuffer, true);
或者通过设置NativeWindowSettings属性TransparentFramebuffer = true。对于整个窗口的透明度,GLFW提供:
GLFW.SetWindowOpacity(WindowPtr, 0.5f); // e.g. half transparency
因此,以上所有内容都很好,但有以下几点:它仅适用于顶级窗口,而不适用于子窗口(我无法发现原因)。因此,前面讨论过Win32 SetLayeredWindowAttributes的使用。此函数具有一些有趣的功能和自身的局限性。我不会深入探讨它的功能(例如,LWA_COLORKEYvs. LWA_ALPHA),但我会提到它自己的转折点,因为它与本文相关:尽管自Windows 8以来,子窗口一直支持此功能,但它只能用于Windows 10及更高版本。在Windows 8.1上,它具有将子窗口从其父窗口的边界中“释放”出来的恶劣效果,即子窗口可以延伸到父窗口的边界之外。此外,透明度似乎只适用于LWA_ALPHA,这使得整个窗口透明/半透明,但不适用于LWA_COLORKEY,其中用于键的颜色仍然呈现为不透明。因此,最好不要在Windows 8/8.1上使用窗口透明度,而是满足于不透明的背景。
SetLayeredWindowAttributes与LWA_LWA_COLORKEY和LWA_ALPHA
_ALPHA在Windows 8.1上不希望的效果:
- 关于鼠标透明度的问题,OpenTK可以通过以下调用来支持这一点:
GLFW.WindowHint(WindowHintBool.MousePassthrough, true);
但是,尽管此处使用的OpenTK版本具有API,但此处使用的底层OpenGL版本(3.3)不支持鼠标直通,因为它仅在3.4中添加。不过没关系,Win32函数EnableWindow可以解决这个问题。
现实生活中的应用
这是使用本文中的解决方案的真实WPF应用程序的低分辨率照片:
OpenGL渲染仅在钢琴卷帘区域,即OpenGL 窗口,钢琴的其余部分(如琴键、框架)和主窗口都是由WPF渲染的内容。该图显示了该区域如何随着整个钢琴和主窗口的移动,即使涉及平移和缩放,其行为也与控件非常相似。
https://www.codeproject.com/Articles/5378432/OpenGL-and-WPF-Integrating-an-OpenGL-Window-inside