Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add GLCanvasElement checks for minimum available OpenGL version #18850

Merged
8 changes: 6 additions & 2 deletions doc/articles/controls/GLCanvasElement.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ uid: Uno.Controls.GLCanvasElement
---

> [!IMPORTANT]
> This functionality is only available on WinAppSDK and Skia Desktop (`netX.0-desktop`) targets that are running on platforms with support for hardware acceleration. On Windows and Linux, OpenGL is used directly and on macOS, Metal is used through the [ANGLE](https://en.wikipedia.org/wiki/ANGLE_(software)) library.
> This functionality is only available on WinAppSDK and Skia Desktop (`netX.0-desktop`) targets that are running on platforms with support for hardware acceleration. On Windows and Linux, OpenGL 3.0+ is used directly and on macOS, Metal is used through the [ANGLE](https://en.wikipedia.org/wiki/ANGLE_(software)) library.

`GLCanvasElement` is a control for drawing 3D graphics with OpenGL. It can be enabled by adding the [`GLCanvas` UnoFeature](xref:Uno.Features.Uno.Sdk). The OpenGL APIs provided are provided by [Silk.NET](https://dotnet.github.io/Silk.NET/).

Expand All @@ -23,7 +23,7 @@ These three abstract methods take a `Silk.NET.OpenGL.GL` parameter that can be u

### The GLCanvasElement constructor

The protected constructor requires a `Func<Window>` argument that fetches the `Microsoft.UI.Xaml.Window` object that the `GLCanvasElement` belongs to. This function is required because WinUI doesn't yet provide a way to get the `Window` of a `FrameworkElement`. This parameter is ignored on Uno Platform and must be set to null. This function is only called while the `GLCanvasElement` is still in the visual tree.
The protected constructor requires a `Func<Window>` argument that fetches the `Microsoft.UI.Xaml.Window` object that the `GLCanvasElement` belongs to. This function is required because WinUI doesn't yet provide a way to get the `Window` of a `FrameworkElement`. This parameter is ignored on Uno Platform and can be set to null. This function is only called while the `GLCanvasElement` is still in the visual tree.

### The `Init` method

Expand All @@ -41,6 +41,10 @@ On MacOS, since OpenGL support is not natively present, we use [ANGLE](https://e

Additionally, `GLCanvasElement` has an `Invalidate` method that requests a redrawing of the `GLCanvasElement`, calling `RenderOverride` in the process. Note that `RenderOverride` will only be called once per `Invalidate` call and the output will be saved to be used in future frames. To update the output, you must call `Invalidate`. If you need to continuously update the output (e.g. in an animation), you can add an `Invalidate` call inside `RenderOverride`.

## Detecting errors

To detect errors in initializing the OpenGL environment, `GLCanvasElement` exposes an `IsGLInitializedProperty` dependency property that shows whether or nor the loading of the element and its OpenGL setup were successful. This property is only valid when the element is loaded, i.e. its `IsLoaded` property is true. When the element is not loaded, the value of `IsGLInitialized` will be null. `GLCanvasElement` implements `INotifyPropertyChanged`, so you can use this property in a data bindings, for example to set the visibility of a control as a fallback. Attempting to change this property is illegal.

## How to use Silk.NET

To learn more about using [Silk.NET](https://www.nuget.org/packages/Silk.NET.OpenGL/) as a C# binding for OpenGL, see the examples in the Silk.NET repository [here](https://github.com/dotnet/Silk.NET/tree/main/examples/CSharp). Note that the windowing and inputs APIs in Silk.NET are not relevant to `GLCanvasElement`, since we only use Silk.NET as an OpenGL binding library, not a windowing library.
Expand Down
Binary file removed src/AddIns/Uno.WinUI.Graphics3DGL/Assets/error.png
Binary file not shown.
124 changes: 109 additions & 15 deletions src/AddIns/Uno.WinUI.Graphics3DGL/GLCanvasElement.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
using Microsoft.UI.Xaml;
Expand All @@ -19,7 +22,6 @@
#endif

#if WINAPPSDK
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
#else
using Uno.Foundation.Extensibility;
Expand All @@ -40,14 +42,17 @@ namespace Uno.WinUI.Graphics3DGL;
public abstract partial class GLCanvasElement : Grid, INativeContext
{
private const int BytesPerPixel = 4;
private static readonly BitmapImage _fallbackImage = new BitmapImage(new Uri("ms-appx:///Assets/error.png"));
private static readonly Dictionary<XamlRoot, INativeOpenGLWrapper> _xamlRootToWrapper = new();
private static readonly Dictionary<XamlRoot, INativeOpenGLWrapper?> _xamlRootToWrapper = new();

private static readonly (int major, int minor) _minVersion = (3, 0);

private readonly Func<Window>? _getWindowFunc;

// valid if and only if _loadedAtleastOnce and OpenGL is available on the running platform
private bool _changingGlInitialized;

// valid if and only if GLCanvasElement was loaded at least once and OpenGL is available on the running platform
private INativeOpenGLWrapper? _nativeOpenGlWrapper;
// These are valid if and only if IsLoaded
// These are valid if and only if IsLoaded and _nativeOpenGlWrapper is not null
private GL? _gl;
private WriteableBitmap? _backBuffer;
private FrameBufferDetails? _details;
Expand Down Expand Up @@ -88,8 +93,6 @@ public abstract partial class GLCanvasElement : Grid, INativeContext
/// </remarks>
protected abstract void RenderOverride(GL gl);

/// <param name="width">The width of the backing framebuffer.</param>
/// <param name="height">The height of the backing framebuffer.</param>
/// <param name="getWindowFunc">A function that returns the Window object that this element belongs to. This parameter is only used on WinUI. On Uno Platform, it can be set to null.</param>
#if WINAPPSDK
protected GLCanvasElement(Func<Window> getWindowFunc)
Expand All @@ -109,7 +112,7 @@ protected GLCanvasElement(Func<Window>? getWindowFunc)
SizeChanged += (_, _) => UpdateFramebuffer();
}

private static INativeOpenGLWrapper? GetOrCreateNativeOpenGlWrapper(XamlRoot xamlRoot, Func<Window>? getWindowFunc)
private static unsafe INativeOpenGLWrapper? GetOrCreateNativeOpenGlWrapper(XamlRoot xamlRoot, Func<Window>? getWindowFunc)
{
try
{
Expand All @@ -119,14 +122,66 @@ protected GLCanvasElement(Func<Window>? getWindowFunc)
#if WINAPPSDK
nativeOpenGlWrapper = new WinUINativeOpenGLWrapper(xamlRoot, getWindowFunc!);
#else
if (!ApiExtensibility.CreateInstance<INativeOpenGLWrapper>(xamlRoot, out nativeOpenGlWrapper))
if (!ApiExtensibility.CreateInstance(xamlRoot, out nativeOpenGlWrapper))
{
throw new InvalidOperationException($"Couldn't create a {nameof(INativeOpenGLWrapper)} object. Make sure you are running on a platform with OpenGL support.");
if (typeof(GLCanvasElement).Log().IsEnabled(LogLevel.Error))
{
typeof(GLCanvasElement).Log().Error($"Couldn't create a {nameof(INativeOpenGLWrapper)} object. Make sure you are running on a platform with OpenGL support.");
}

_xamlRootToWrapper[xamlRoot] = null;
return null;
}
#endif

var abort = false;
using (nativeOpenGlWrapper.MakeCurrent())
{
var glGetString = (delegate* unmanaged[Cdecl]<GLEnum, byte*>)nativeOpenGlWrapper.GetProcAddress("glGetString");

var glVersionBytePtr = glGetString(GLEnum.Version);
var glVersionString = Marshal.PtrToStringUTF8((IntPtr)glVersionBytePtr);

if (typeof(GLCanvasElement).Log().IsEnabled(LogLevel.Information))
{
typeof(GLCanvasElement).Log().Info($"{nameof(GLCanvasElement)} created an OpenGL context with a version string = '{glVersionString}'.");
}

if (glVersionString?.Contains("ANGLE", StringComparison.Ordinal) ?? false)
{
if (typeof(GLCanvasElement).Log().IsEnabled(LogLevel.Warning))
{
typeof(GLCanvasElement).Log().Warn($"{nameof(GLCanvasElement)} is using an ANGLE implementation, ignoring minimum version checks.");
}
}
else
{
var glGetIntegerv = (delegate* unmanaged[Cdecl]<GLEnum, int*, void>)nativeOpenGlWrapper.GetProcAddress("glGetIntegerv");
int major, minor;
glGetIntegerv(GLEnum.MajorVersion, &major);
glGetIntegerv(GLEnum.MinorVersion, &minor);

if (major < _minVersion.major || (major == _minVersion.major && minor < _minVersion.minor))
{
if (typeof(GLCanvasElement).Log().IsEnabled(LogLevel.Error))
{
typeof(GLCanvasElement).Log().Error($"{nameof(GLCanvasElement)} requires at least {_minVersion.major}.{_minVersion.minor}, but found {major}.{minor}.");
}

abort = true;
}
}
}

if (abort)
{
nativeOpenGlWrapper.Dispose();
nativeOpenGlWrapper = null;
}

_xamlRootToWrapper.Add(xamlRoot, nativeOpenGlWrapper);
}

return nativeOpenGlWrapper;
}
catch (Exception e)
Expand All @@ -151,8 +206,8 @@ private void OnClosed(object _, object __)
}
if (_xamlRootToWrapper.Remove(XamlRoot!, out var wrapper))
{
using var _ = wrapper.MakeCurrent();
wrapper.Dispose();
using var makeCurrentDisposable = wrapper?.MakeCurrent();
wrapper?.Dispose();
}
});
}
Expand All @@ -169,18 +224,54 @@ private void OnClosed(object _, object __)
public void Invalidate() => NativeDispatcher.Main.Enqueue(Render, NativeDispatcherPriority.Idle);
#endif

public static DependencyProperty IsGLInitializedProperty { get; } =
DependencyProperty.Register(
nameof(IsGLInitialized),
typeof(bool?),
typeof(GLCanvasElement),
new PropertyMetadata(null, (PropertyChangedCallback)((dO, _) =>
{
var @this = (GLCanvasElement)dO;
if (!@this._changingGlInitialized)
{
throw new InvalidOperationException($"{nameof(GLCanvasElement)}.{nameof(IsGLInitializedProperty)} is read-only.");
}

// We should have arrived here from set_IsGLInitialized, so we could put this line at the end of the
// setter. Instead, we set it to false here to prevent users from calling SetValue.IsGLInitializedProperty
// _inside_ a call to GLCanvasElement.set_IsGLInitialized. This way, if a user intercepts this
// change (e.g. with SubscribeToPropertyChanged) and attempts to make a nested SetValue call, we still
// explode in their face.
@this._changingGlInitialized = false;
})));

/// <summary>
/// Indicates whether this element was loaded successfully or not, including the OpenGL context creation and setup.
/// This property is only valid when the element is loaded. When the element is not loaded in the visual tree, the value will be null.
/// </summary>
public bool? IsGLInitialized
{
get => (bool?)GetValue(IsGLInitializedProperty);
private set
{
_changingGlInitialized = true;
SetValue(IsGLInitializedProperty, value);
}
}

private void OnLoaded(object sender, RoutedEventArgs routedEventArgs)
{
_nativeOpenGlWrapper = GetOrCreateNativeOpenGlWrapper(XamlRoot!, _getWindowFunc);

if (_nativeOpenGlWrapper is null)
{
IsGLInitialized = false;
return;
}

_gl = GL.GetApi(this);

using (_nativeOpenGlWrapper!.MakeCurrent())
using (_nativeOpenGlWrapper.MakeCurrent())
{
UpdateFramebuffer();
Init(_gl);
Expand All @@ -200,10 +291,13 @@ private void OnLoaded(object sender, RoutedEventArgs routedEventArgs)
{
fe.Unloaded += OnClosed;
}

IsGLInitialized = true;
}

private void OnUnloaded(object sender, RoutedEventArgs routedEventArgs)
{
IsGLInitialized = null;
if (_nativeOpenGlWrapper is null)
{
return;
Expand Down Expand Up @@ -256,7 +350,7 @@ private void OnUnloaded(object sender, RoutedEventArgs routedEventArgs)

private void UpdateFramebuffer()
{
if (!IsLoaded)
if (!IsLoaded || _nativeOpenGlWrapper is null)
{
return;
}
Expand Down Expand Up @@ -290,7 +384,7 @@ private void UpdateFramebuffer()

private unsafe void Render()
{
if (!IsLoaded)
if (!IsLoaded || _nativeOpenGlWrapper is null)
{
return;
}
Expand Down
Loading