65.9K
CodeProject 正在变化。 阅读更多。
Home

OpenGL 和 WPF: 在 WPF 窗口中集成 OpenGL 窗口

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2024年3月1日

CPOL

18分钟阅读

viewsIcon

6830

downloadIcon

210

演示了如何在 .NET WPF 应用程序中使用 OpenGL,通过将 OpenGL 窗口直接作为主 WPF 窗口的子窗口来集成,使其行为更像一个控件。

引言

本文使用 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**,这是撰写本文时最新的稳定版本。

Visual Studio project structure

代码基于 OpenTK 教程中的“Hello Triangle”示例,该教程托管在 GitHub 上。与本文相关的类是 MainWindowGLWindow。我将 GLWindow 的实现拆分为两个源文件。并非我赞同这种做法,但这样做是为了帮助大家专注于本文的重要内容。文件 GLWindow.article.cs 包含本文相关的部分——OpenGL 窗口的创建和管理逻辑。文件 GLWindow.tutorial.cs 以及着色器文件涵盖了 OpenGL 特定的代码,我不会过多强调,因为这些内容直接来自上述 OpenTK 教程,并且在那里得到了最好的阐述。还有一个小的 staticInterop,它充当一些必需的 Win32 函数的 PInvoke 包装器。

GLWindow 构造函数

GLWindowOpenTKGameWindow 的子类,负责 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_TRANSPARENTSetLayeredWindowAttributes 结合使用以启用透明度。

接下来的调用如下:

  • EnableWindow,它禁用对窗口的鼠标和键盘输入。鼠标事件将穿透到下方的 WPF 窗口,键盘事件也将被定向到父 WPF 窗口。
  • SetLayeredWindowAttributes 设置窗口的透明度。在此情况下,通过 0x00bbggrr 值传递的颜色键 (LWA_COLORKEY) 将黑色 (0x00000000) 设置为透明颜色。这与 OpenGL 渲染中使用的背景色相匹配,意味着窗口背景是透明的。所有其他渲染的颜色将是不透明的,这正是我们想要的。

最后,我们通过调用 GLFW.ShowWindow 显示窗口,并记录窗口的初始位置,以便在必要时更新它。

构造函数到此结束;关于 SetLayeredWindowAttributes 以及 OpenTK 对透明背景、窗口透明度和鼠标穿透的支持,还有更多内容要评论,但我将在文章末尾的**结论**部分进行评论。

GLWindow 线程

此时,我们有一个子窗口,它被限制在其父窗口内,无边框,禁用鼠标和键盘输入,背景透明。我们已经非常接近满足引言中的要求了。

GLWindow 有一个从其父类 GameWindow 继承的渲染循环——Run 方法——它会使调用线程忙碌。这意味着该线程不能与主线程(即 WPF UI 线程)相同。因此,窗口在 Task 中创建和运行,该 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 成员中的任务。主线程传递父窗口的 Win32 HANDLE(包装在 IntPtr 中),这是 SetParent 所必需的,以及子窗口的初始边界。任务通过其构造函数简单地创建子窗口,然后启动其渲染循环,该循环是继承的 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 是控制渲染的一个简单示例。虽然在这里它只是停止或重新启动三角形的旋转,但在实际应用中,这可以扩展为更高级的功能,例如控制要渲染的对象。

GLWindowConcurrentQueue 的消费者,该队列存储在成员 _commands 中。但问题是,在哪里可以合适地消费这个队列?OpenTK 的教程表明,重写的 OnUpdateFrame 是处理键盘和鼠标输入的地方。在我们的例子中,窗口不处理任何键盘或鼠标输入,因此我们用处理“命令输入”来替换该处理。由于 OnUpdateFrameOnRenderFrame 一样由渲染循环 Run 调用,因此队列中的任何 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 并调用前面讨论的 static 方法 CreateAndRunGetRenderAreaBounds 方法将在下一段中讨论。在父窗口的 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,它会将命令加入队列,最终更新子窗口的 ClientLocationClientSize 属性。

通过箭头键移动子窗口来平移三角形

主窗口的 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 方法将一个命令加入队列,用于子窗口,最终更新其 ClientLocationClientSize。还要注意的是,尽管箭头键将三角形移动到了中心单元格之外,但只要主窗口被调整大小,三角形就会“吸附”回中心单元格!

注意:通过移动窗口来进行平移只是为了展示:

  • 我们可以将此类命令发送到窗口
  • 主窗口(父窗口)仍然接收键盘输入
  • 窗口在到达父窗口边界时会被父窗口裁剪
  • 我们具有鼠标透明度,并且可以在窗口覆盖按钮时点击它们

一个真正的(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,绝对有用。

结论和关注点

我已展示了如何通过将普通的 OpenTK 窗口直接嵌入 WPF 窗口中,像控件一样利用 WPF 窗口中的 OpenGL 渲染。这不需要任何 DX 互操作,因为 DX 互操作会增加间接层并影响(在我看来,破坏)性能。下载压缩的源代码,构建 VS 解决方案,亲自尝试一下。

关于本文的一些相关主题,还有一些想法,列在下面作为额外的兴趣点。

  • 在单独的线程中创建和运行 GameWindow 是 OpenTK 最近才开放的功能,因为它不是跨平台的——在其 GitHub 存储库中有一个关于此的有趣讨论。另一方面,本文是关于 WPF 的,因此我们只针对 Windows。

  • 我不太确定的是,如果同时创建多个 OpenGL 渲染区域,每个区域都有自己的窗口,会发生什么。就此而言,似乎 GLWfpControl 本身在多个实例方面存在一些挑战。这些场景我没太大兴趣深入研究,因为对我来说,它们是较不常见的用例。

  • 你可能已经注意到,每当使用 GameWinodw 的不安全指针 WindowPtr 时,都会出现 unsafe 代码块。编译器需要 /unsafe 编译器参数来编译项目,这可以在项目设置中指定。如果你对此感到不适,可以将 OpenGL 相关代码隔离到一个单独的库中,而不影响 WPF 应用程序项目的其余部分。

  • 源代码中没有多少防御性编程,例如错误检查,因为这是一个演示应用程序,而不是实际应用。在实际应用中,我们至少应该检查要使用的 OpenGL 版本是否在该系统上受支持。这需要将窗口创建调用放在 try...catch 块中。

    try {
        glWnd = new GLWindow(hWndParent, bounds);
    }
    catch (GLFWException ex) when (ex.ErrorCode == ErrorCode.VersionUnavailable) {
        // Error - OpenGL version not supported
    }
  • 关于透明度主题,GLFW 文档指出,GLFW 支持帧缓冲区透明度(可用于创建透明/半透明背景)以及整个窗口透明度。OpenTK 也将此支持带到了 .NET:可以通过调用类似以下的代码来启用帧缓冲区透明度:

    GLFW.WindowHint(WindowHintBool.TransparentFramebuffer, true);
    或者通过设置 NativeWindowSettings 属性 TransparentFramebuffer = true。对于整个窗口透明度,GLFW 提供:
    GLFW.SetWindowOpacity(WindowPtr, 0.5f); // e.g. half transparency

    所以,以上所有内容都很好很真实,但有一个转折:它只适用于顶层窗口,而不适用于子窗口(我还没弄明白为什么)。因此,使用了前面讨论的 Win32 函数 SetLayeredWindowAttributes。这个函数有一些有趣的功能,也有它自己的局限性。我不会深入探讨它的功能(例如 LWA_COLORKEYLWA_ALPHA),但我要提及它的转折,因为它与本文相关:尽管自 Windows 8 起,该函数就支持子窗口,但仅在 Windows 10 及更高版本上才能用于我们的目的。在 Windows 8.1 上,它有一个糟糕的效果,即“释放”子窗口的父窗口边界,也就是说,子窗口可以超出父窗口的边界。此外,透明度似乎仅在使用 LWA_ALPHA 时有效,这使整个窗口透明/半透明,但 LWA_COLORKEY 无效,其中用于键的颜色仍显示为不透明。因此,最好**不要**在 Windows 8/8.1 上使用窗口透明度,而满足于不透明的背景。

    SetLayeredWindowAttributes 在 Windows 8.1 上对 LWA_COLORKEYLWA_ALPHA 的不期望的效果

    App on Windows 8.1

  • 关于**鼠标透明度**主题,OpenTK 可以通过调用类似以下的代码来支持它:

    GLFW.WindowHint(WindowHintBool.MousePassthrough, true);
    但是,虽然这里使用的 OpenTK 版本具有此 API,但这里使用的底层 OpenGL 版本(3.3)不支持鼠标穿透,因为它只在 3.4 中添加。不过没关系,Win32 函数 EnableWindow 会处理这个问题。

实际应用

这是本文解决方案在实际 WPF 应用程序中的低分辨率截图。

Real life app

OpenGL 渲染仅在钢琴卷帘区域进行,这是一个 OpenGL **窗口**,钢琴的其他部分(如琴键、框架)以及主窗口是 WPF 渲染的内容。图中显示了该区域如何随整个钢琴和主窗口一起移动,即使在平移和缩放的情况下,其行为也与**控件**几乎一样。

历史

  • 2024 年 2 月 29 日:初始版本
© . All rights reserved.