From 21460c32cadb77554552e20ab1b0a1d0f54c834c Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Sat, 8 Nov 2025 19:58:39 -0700 Subject: [PATCH 01/14] Add Sdl2 display implementation * Added an implementation of IDisplay using Sdl2. * Created a new Catalyst.Modules.Crystal.Sdl2 project. --- .scripts/Setup.ps1 | 8 + CatalystUI/CatalystUI.sln | 7 + .../NativeConnectors/EdidHelper.cs | 2 +- .../Window/GlfwDisplay.cs | 2 +- .../CatalystUI.Modules.Crystal.Sdl2.csproj | 25 +++ .../CatalystAppBuilderExtensions.cs | 55 +++++ .../CatalystUI.Modules.Crystal.Sdl2/Sdl2.cs | 211 ++++++++++++++++++ .../Sdl2WindowLayer.cs | 76 +++++++ .../Window/SdlDisplay.cs | 132 +++++++++++ 9 files changed, 516 insertions(+), 2 deletions(-) rename CatalystUI/Modules/Crystal/{CatalystUI.Modules.Crystal.Glfw3 => CatalystUI.Modules.Crystal.Core}/NativeConnectors/EdidHelper.cs (96%) create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/CatalystUI.Modules.Crystal.Sdl2.csproj create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Extensions/CatalystAppBuilderExtensions.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlDisplay.cs diff --git a/.scripts/Setup.ps1 b/.scripts/Setup.ps1 index 999152a..66d11ef 100644 --- a/.scripts/Setup.ps1 +++ b/.scripts/Setup.ps1 @@ -87,6 +87,14 @@ $projectsList = @( ) PromptIgnore = $false Depends = @("Crystal") + }, + @{ + Module = "Crystal.Sdl2" + Projects = @( + @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.Sdl2" } + ) + PromptIgnore = $false + Depends = @("Crystal") } ) diff --git a/CatalystUI/CatalystUI.sln b/CatalystUI/CatalystUI.sln index 6a5b756..aea7293 100644 --- a/CatalystUI/CatalystUI.sln +++ b/CatalystUI/CatalystUI.sln @@ -40,6 +40,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.Glfw3", "Modules\Crystal\CatalystUI.Modules.Crystal.Glfw3\CatalystUI.Modules.Crystal.Glfw3.csproj", "{BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.Sdl2", "Modules\Crystal\CatalystUI.Modules.Crystal.Sdl2\CatalystUI.Modules.Crystal.Sdl2.csproj", "{532C59A0-43D2-44E9-B6ED-E88E6B8770F5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,6 +104,10 @@ Global {BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18}.Debug|Any CPU.Build.0 = Debug|Any CPU {BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18}.Release|Any CPU.ActiveCfg = Release|Any CPU {BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18}.Release|Any CPU.Build.0 = Release|Any CPU + {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {68F496AC-9438-40F1-9DF8-97363033D661} = {7EC51871-49A8-4991-BDAF-F43A4E9B8C9D} @@ -121,5 +127,6 @@ Global {047744B0-87DC-4808-99C4-5AC1F8A1EB4D} = {C8B02B42-826B-4EDE-B72F-F4F97C1A088D} {4DE1A1EB-103B-4E07-859A-354908ACE0E2} = {41BEF490-7005-4D10-9958-22D636F9DE38} {BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18} = {41BEF490-7005-4D10-9958-22D636F9DE38} + {532C59A0-43D2-44E9-B6ED-E88E6B8770F5} = {41BEF490-7005-4D10-9958-22D636F9DE38} EndGlobalSection EndGlobal diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/EdidHelper.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/NativeConnectors/EdidHelper.cs similarity index 96% rename from CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/EdidHelper.cs rename to CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/NativeConnectors/EdidHelper.cs index c063ec1..48a9f0b 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/EdidHelper.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/NativeConnectors/EdidHelper.cs @@ -12,7 +12,7 @@ using System.Text; // ReSharper disable once CheckNamespace -namespace Catalyst.Modules.Crystal.Glfw3 { +namespace Catalyst.Modules.Crystal { // TODO: Maybe make this an Arcane thing? Seems reasonable enough. /// diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwDisplay.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwDisplay.cs index 5aad99d..039149c 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwDisplay.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwDisplay.cs @@ -25,7 +25,7 @@ namespace Catalyst.Modules.Crystal.Glfw3 { public readonly record struct GlfwDisplay : IDisplay { /// - /// Gets the Glfw3 monitor handle for this display. + /// Gets the Glfw3 monitor handle for the display. /// /// The display's Glfw3 monitor handle. public required nint Monitor { get; init; } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/CatalystUI.Modules.Crystal.Sdl2.csproj b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/CatalystUI.Modules.Crystal.Sdl2.csproj new file mode 100644 index 0000000..d8af19a --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/CatalystUI.Modules.Crystal.Sdl2.csproj @@ -0,0 +1,25 @@ + + + + + + Catalyst.Modules.Crystal.Sdl2 + Catalyst.Modules.Crystal.Sdl2 + true + + + CatalystUI Crystal – Sdl2 + 1.0.0 + alpha.1 + CatalystUI LLC + Sdl2 API for the Crystal subset of modules provided by the CatalystUI library. + CatalystUI,Crystal,sdl2,sdl,ui,gui,graphics,visual,window + + + + + + + + + \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Extensions/CatalystAppBuilderExtensions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Extensions/CatalystAppBuilderExtensions.cs new file mode 100644 index 0000000..53b4e03 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Extensions/CatalystAppBuilderExtensions.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Modules.Crystal.Sdl2; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Builders.Extensions { + + /// + /// Builder extensions for the . + /// + public static class CatalystAppBuilderExtensions { + + /// + /// Adds the Crystal-based Sdl2 windowing module to the . + /// + /// + /// + /// The Crystal-based Sdl2 windowing module adds the following to your CatalystUI application: + /// + /// + /// + /// + /// Click on any of the above links to learn more about each component. + /// + /// + /// The to add the module to. + /// The with the Sdl2 windowing module added. + public static CatalystAppBuilder AddCrystalSdl2Module(this CatalystAppBuilder builder) { + Sdl2WindowLayer glfw3WindowingLayer = new(); + ModelRegistry.RegisterLayer(glfw3WindowingLayer); + // if (SystemDetector.IsSystem()) { + // throw new NotImplementedException(); + // } else if (SystemDetector.IsSystem()) { + // throw new NotImplementedException(); + // } else if (SystemDetector.IsSystem()) { + // throw new NotImplementedException(); + // } else { + // // Native functionality for Glfw3 is OPTIONAL. + // // Do not throw an error here unless it becomes REQUIRED. + // } + return builder; + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2.cs new file mode 100644 index 0000000..ab722a9 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2.cs @@ -0,0 +1,211 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Attributes.Threading; +using Catalyst.Debugging; +using Catalyst.Native; +using Catalyst.Threading; +using Silk.NET.SDL; +using System; +using System.Threading; + +namespace Catalyst.Modules.Crystal.Sdl2 { + + /// + /// Native API wrapper for the Sdl2 library. + /// + public sealed partial class Sdl2 : INativeApi { + + /// + /// Default initialization flags for the Sdl2 API. + /// + public const uint DEFAULT_INIT_FLAGS = + Sdl.InitVideo | + Sdl.InitEvents | + Sdl.InitTimer | + Sdl.InitGamecontroller; + + /// + /// The underlying wrapped API instance. + /// + private static Sdl? _api; + + /// + /// The dispatcher which was used to initialize the API. + /// + private static ThreadDelegateDispatcher? _initDispatcher; + + /// + /// The total number of 'requests' or instantiations of the API. + /// + private static ushort _referenceCount; + + /// + /// A static lock used to ensure thread-safe access to the static members. + /// + private static readonly Lock _staticLock; + + /// + /// Gets the Sdl2 debug context. + /// + /// The debug context for Sdl2. + public static DebugContext DebugContext { get; } + + /// + /// Gets the wrapped API instance. + /// + /// The wrapped Sdl2 API instance. + public Sdl Api { + get { + _staticLock.Enter(); + try { + return _api!; // Non-nullable because it is initialized in the static constructor + } finally { + _staticLock.Exit(); + } + } + } + + /// + /// A flag indicating whether the object has been disposed of. + /// + private bool _disposed; + + /// + /// A lock used to ensure thread-safe access to the object. + /// + private readonly Lock _lock; + + /// + /// Static constructor for + /// + static Sdl2() { + // Fields + _referenceCount = 0; + _staticLock = new(); + + // Properties + DebugContext = CatalystDebug.ForContext("Sdl2"); + } + + /// + /// Constructs a new . + /// + private Sdl2() { + // Fields + _disposed = false; + _lock = new(); + } + + /// + /// Disposes of the . + /// + ~Sdl2() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: false); + } + + /// + public static Sdl2 GetInstance(ThreadDelegateDispatcher? dispatcher = null) { + _staticLock.Enter(); + try { + if (_referenceCount == 0) Initialize(dispatcher); + _referenceCount++; + return new(); + } finally { + _staticLock.Exit(); + } + } + + /// + /// Initializes the Sdl2 API. + /// + private static void Initialize(ThreadDelegateDispatcher? dispatcher = null) { + if (dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(Sdl2), nameof(GetInstance)); + dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } + if (!dispatcher.Execute(_cachedActionInitializeUnsafe, wait: true)) { + throw new TypeInitializationException(nameof(Sdl2), new InvalidOperationException("Failed to initialize Sdl2 API on the main thread.")); + } + _initDispatcher = dispatcher; + } + + [CachedDelegate] + private static void InitializeUnsafe() { + if (_api != null) throw new InvalidOperationException("The Sdl2 API is already initialized."); + _api = Sdl.GetApi(); + _api.Init(DEFAULT_INIT_FLAGS); + if ((_api.WasInit(DEFAULT_INIT_FLAGS) & DEFAULT_INIT_FLAGS) != DEFAULT_INIT_FLAGS) { + throw new WindowException("Sdl2 failed to initialize required subsystems."); + } + } + + /// + /// Terminates the Sdl2 API. + /// + private static void Terminate(ThreadDelegateDispatcher? dispatcher = null) { + if (dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(Sdl2), nameof(Terminate)); + dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } + if (!dispatcher.Execute(_cachedActionTerminateUnsafe, wait: true)) { + throw new InvalidOperationException("Failed to terminate Sdl2 API on the main thread."); + } + } + + [CachedDelegate] + private static void TerminateUnsafe() { + _api?.Quit(); + _api?.Dispose(); + _api = null; + } + + /// + /// Disposes of the . + /// + public void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// if disposal is being performed by the garbage collector, otherwise + /// + private void Dispose(bool disposing) { + _lock.Enter(); + try { + if (_disposed) return; + + // Dispose managed state (managed objects) + if (disposing) { + // ... + } + + // Dispose unmanaged state (unmanaged objects) + _staticLock.Enter(); + try { + _referenceCount--; + if (_referenceCount == 0) Terminate(_initDispatcher); + } finally { + _staticLock.Exit(); + } + + // Indicate disposal completion + _disposed = true; + } finally { + _lock.Exit(); + } + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs new file mode 100644 index 0000000..7544cea --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Attributes.Threading; +using Catalyst.Threading; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Catalyst.Modules.Crystal.Sdl2 { + + /// + /// The Crystal implementation of the CatalystUI type + /// using the Sdl2 windowing API. + /// + public sealed unsafe partial class Sdl2WindowLayer : IWindowLayer { + + /// + public IEnumerable GetDisplays(ThreadDelegateDispatcher? dispatcher = null) { + if (dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(Sdl2WindowLayer), nameof(GetDisplays)); + dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } + if (!dispatcher.Execute(_cachedFunctionGetDisplaysUnsafe, dispatcher, out SdlDisplay[] displays)) { + throw new WindowException("Failed to get displays from Sdl2 on the main thread."); + } + return displays.OfType(); + } + + [CachedDelegate] + internal static SdlDisplay[] GetDisplaysUnsafe(ThreadDelegateDispatcher dispatcher) { + using Sdl2 sdl = Sdl2.GetInstance(dispatcher); + int count = sdl.Api.GetNumVideoDisplays(); + if (count == 0) return []; + SdlDisplay[] displays = new SdlDisplay[count]; + for (int i = 0; i < count; i++) { + displays[i] = SdlDisplay.FromIndex(sdl, i); + } + return displays; + } + + /// + public IDisplay? GetPrimaryDisplay(ThreadDelegateDispatcher? dispatcher = null) { + if (dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(Sdl2WindowLayer), nameof(GetPrimaryDisplay)); + dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } + if (!dispatcher.Execute(_cachedFunctionGetPrimaryDisplayUnsafe, dispatcher, out SdlDisplay? display)) { + throw new WindowException("Failed to get primary display from Sdl2 on the main thread."); + } + return display; + } + + [CachedDelegate] + internal static SdlDisplay? GetPrimaryDisplayUnsafe(ThreadDelegateDispatcher dispatcher) { + using Sdl2 sdl = Sdl2.GetInstance(dispatcher); + SdlDisplay display = SdlDisplay.FromIndex(sdl, 0); + return display; + } + + /// + public IWindow CreateWindow(WindowOptions? options = null) { + throw new NotImplementedException(); + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlDisplay.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlDisplay.cs new file mode 100644 index 0000000..e003dab --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlDisplay.cs @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Debugging; +using Catalyst.Mathematics.Geometry; +using Silk.NET.Maths; +using Silk.NET.SDL; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Modules.Crystal.Sdl2 { + + /// + /// An implementation of using the Sdl2 windowing API. + /// + public readonly record struct SdlDisplay : IDisplay { + + /// + /// Gets the Sdl2 display index for the display. + /// + /// The display's Sdl2 index. + public required int Index { get; init; } + + /// + public required string Descriptor { get; init; } + + /// + public required string? Manufacturer { get; init; } + + /// + public required double RefreshRate { get; init; } + + /// + public required double X { get; init; } + + /// + public required double Y { get; init; } + + /// + public required uint Width { get; init; } + + /// + public required uint Height { get; init; } + + /// + public required Angle Rotation { get; init; } + + /// + public required DisplayOrientation Orientation { get; init; } + + /// + public required double PixelsPerInch { get; init; } + + /// + public required double ScalingFactor { get; init; } + + /// + /// Constructs a new from the specified index. + /// + /// The Sdl2 API instance. + /// The index of the display to construct from. + /// A new instance of . + public static unsafe SdlDisplay FromIndex(Sdl2 sdl, int index) { + // TODO: Native display names and manufacturers if possible + // TODO: Native angle, orientation + + // Parse display mode + DisplayMode mode = default; + sdl.Api.GetDesktopDisplayMode(index, ref mode); + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Parsed display mode for display {index}: {mode.W}x{mode.H} @ {mode.RefreshRate}Hz, format {mode.Format}"); + + // Parse display bounds + Rectangle bounds = default; + sdl.Api.GetDisplayBounds(index, ref bounds); + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Parsed display bounds for display {index}: Position ({bounds.Origin.X}, {bounds.Origin.Y}), Size ({bounds.Size.X}x{bounds.Size.Y})"); + + // Parse display orientation + Silk.NET.SDL.DisplayOrientation sdlOrientation = sdl.Api.GetDisplayOrientation(index); + DisplayOrientation orientation = sdlOrientation switch { + Silk.NET.SDL.DisplayOrientation.Landscape => DisplayOrientation.Landscape, + Silk.NET.SDL.DisplayOrientation.LandscapeFlipped => DisplayOrientation.LandscapeFlipped, + Silk.NET.SDL.DisplayOrientation.Portrait => DisplayOrientation.Portrait, + Silk.NET.SDL.DisplayOrientation.PortraitFlipped => DisplayOrientation.PortraitFlipped, + _ => DisplayOrientation.Landscape + }; + Angle rotation = sdlOrientation switch { + Silk.NET.SDL.DisplayOrientation.Landscape => Angle.FromDegrees(0), + Silk.NET.SDL.DisplayOrientation.LandscapeFlipped => Angle.FromDegrees(180), + Silk.NET.SDL.DisplayOrientation.Portrait => Angle.FromDegrees(270), + Silk.NET.SDL.DisplayOrientation.PortraitFlipped => Angle.FromDegrees(90), + _ => Angle.FromDegrees(0) + }; + + // Parse display DPI + float ddpi = 0.0f; + float hdpi = 0.0f; + float vdpi = 0.0f; + sdl.Api.GetDisplayDPI(index, ref ddpi, ref hdpi, ref vdpi); + double ppi = (hdpi + vdpi) / 2.0; + double scale = ppi / 96.0; + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Parsed display DPI for display {index}: Horizontal DPI {hdpi}, Vertical DPI {vdpi}, Average PPI {ppi}, Scaling Factor {scale}"); + + // Parse display descriptor + string descriptor = sdl.Api.GetDisplayNameS(index); + + // Construct and return + return new() { + Index = index, + Descriptor = descriptor, + Manufacturer = "", + RefreshRate = mode.RefreshRate, + X = bounds.Origin.X, + Y = bounds.Origin.Y, + Width = (uint) bounds.Size.X, + Height = (uint) bounds.Size.Y, + Rotation = rotation, + Orientation = orientation, + PixelsPerInch = ppi, + ScalingFactor = scale + }; + } + + } + +} \ No newline at end of file From da57b2ab87105c4417564d1a5955f4491a6bf463 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Sat, 8 Nov 2025 22:11:40 -0700 Subject: [PATCH 02/14] Add Sdl2 window implementaton * Added an implementation of IWindow using Sdl2. --- .../Window/GlfwWindow.cs | 7 +- .../Sdl2WindowLayer.cs | 3 +- .../Window/SdlWindow.cs | 960 ++++++++++++++++++ 3 files changed, 965 insertions(+), 5 deletions(-) create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs index 1fce8e0..7a5b945 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs @@ -478,6 +478,7 @@ public GlfwWindow(WindowOptions options) { Glfw3.DebugContext.Log(LogLevel.Verbose, "Done."); // Assign cached displays to avoid multiple creations/destruction of the API + _cachedPrimaryDisplay = Glfw3WindowLayer._cachedFunctionGetPrimaryDisplayUnsafe(Dispatcher); _cachedDisplays = Glfw3WindowLayer._cachedFunctionGetDisplaysUnsafe(Dispatcher); // Specify no client API @@ -1024,7 +1025,7 @@ protected virtual void OnErrored(WindowException exception) { /// protected virtual void OnCreated() { - Glfw3.DebugContext.Log(LogLevel.Info, $"Window created.", prefix: LogId); + Glfw3.DebugContext.Log(LogLevel.Info, "Window created.", prefix: LogId); Created?.Invoke(this); } @@ -1132,8 +1133,8 @@ private void Dispose(bool disposing) { if (disposing) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if (Dispatcher != null) { - Dispatcher.PreExecute -= HandlePreExecute; - Dispatcher.DelegateEnqueued -= HandleDelegateEnqueued; + Dispatcher.PreExecute -= _handlePreExecute; + Dispatcher.DelegateEnqueued -= _handleDelegateEnqueued; } // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs index 7544cea..94e9f56 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Sdl2WindowLayer.cs @@ -11,7 +11,6 @@ using Catalyst.Attributes.Threading; using Catalyst.Threading; -using System; using System.Collections.Generic; using System.Linq; @@ -68,7 +67,7 @@ internal static SdlDisplay[] GetDisplaysUnsafe(ThreadDelegateDispatcher dispatch /// public IWindow CreateWindow(WindowOptions? options = null) { - throw new NotImplementedException(); + return new SdlWindow(options ?? new()); } } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs new file mode 100644 index 0000000..190e00f --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs @@ -0,0 +1,960 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Debugging; +using Catalyst.Supplementary; +using Catalyst.Threading; +using Silk.NET.Core.Native; +using Silk.NET.SDL; +using System; +using System.Linq; +using System.Threading; +using Mutex = System.Threading.Mutex; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Modules.Crystal.Sdl2 { + + /// + /// An implementation of using + /// the Sdl2 windowing API. + /// + public unsafe class SdlWindow : IWindow { + + /// + /// The minimum polling rate for the window in milliseconds. + /// + /// + /// Prevents lockups of the main thread when the + /// window may not be responding. + /// + public const ushort MINIMUM_POLL_RATE = 3000; + + /// + public event IWindow.WindowErroredEventHandler? Errored; + + /// + public event IWindow.WindowEventHandler? Created; + + /// + public event IWindow.WindowEventHandler? Repositioned; + + /// + public event IWindow.WindowEventHandler? Resized; + + /// + public event IWindow.WindowEventHandler? Refresh; + + /// + public event IWindow.WindowEventHandler? Redraw; + + /// + public event IWindow.WindowEventHandler? Focused; + + /// + public event IWindow.WindowEventHandler? Unfocused; + + /// + public event IWindow.WindowEventHandler? Minimized; + + /// + public event IWindow.WindowEventHandler? Maximized; + + /// + public event IWindow.WindowEventHandler? Restored; + + /// + public event IWindow.WindowEventHandler? Shown; + + /// + public event IWindow.WindowEventHandler? Hidden; + + /// + public event IWindow.WindowClosingEventHandler? Closing; + + /// + public event IWindow.WindowEventHandler? Closed; + + /// + public event IWindow.WindowInteractedEventHandler? Interacted; + + /// + /// Gets a generated log identifier for the window. + /// + /// The window class name and native handle in hexadecimal format. + protected string LogId => $"{nameof(SdlWindow)} {(NativeHandle.Length > 0 ? $"0x{NativeHandle[0]:X}" : "(No Native Handle)")}"; + + /// + /// Gets or sets the Sdl2 instance associated with the window. + /// + /// The Sdl2 instance. + public Sdl2 Sdl { get; private set; } + + /// + /// Gets or sets the Sdl2 window handle. + /// + /// The Sdl2 window handle. + public nint SdlHandle { get; private set; } + + /// + /// Gets or sets the Sdl2 window ID. + /// + /// The Sdl2 window ID. + public uint SdlId { get; private set; } + + /// + public ThreadDelegateDispatcher Dispatcher { get; init; } + + /// + /// Internal reference for . + /// + protected nint[] _nativeHandle; + + /// + public nint[] NativeHandle => _nativeHandle; + + /// + /// Internal reference for . + /// + protected volatile ushort _pollRate; + + /// + public virtual ushort PollRate { + get => _pollRate; + set => throw new NotImplementedException(); + } + + /// + /// Internal reference for . + /// + protected volatile IDisplay? _display; + + /// + public virtual IDisplay? Display => _display; + + /// + /// Internal reference for . + /// + protected volatile string _title; + + /// + public virtual string Title { + get => _title; + set => throw new NotImplementedException(); + } + + /// + /// Internal reference for . + /// + protected double _x; + + /// + public virtual double X { + get => Volatile.Read(ref _x); + set => SetPosition(value, Y); + } + + /// + /// Internal reference for . + /// + protected double _y; + + /// + public virtual double Y { + get => Volatile.Read(ref _y); + set => SetPosition(X, value); + } + + /// + /// Internal reference for . + /// + protected volatile uint _minimumWidth; + + /// + public virtual uint MinimumWidth { + get => _minimumWidth; + set => SetSizeLimits(value, MinimumHeight, MaximumWidth, MaximumHeight); + } + + /// + /// Internal reference for . + /// + protected volatile uint _width; + + /// + public virtual uint Width { + get => _width; + set => SetSize(value, Height); + } + + /// + /// Internal reference for . + /// + protected volatile uint _maximumWidth; + + /// + public virtual uint MaximumWidth { + get => _maximumWidth; + set => SetSizeLimits(MinimumWidth, MinimumHeight, value, MaximumHeight); + } + + /// + /// Internal reference for . + /// + protected volatile uint _minimumHeight; + + /// + public virtual uint MinimumHeight { + get => _minimumHeight; + set => SetSizeLimits(MinimumWidth, value, MaximumWidth, MaximumHeight); + } + + /// + /// Internal reference for . + /// + protected volatile uint _height; + + /// + public virtual uint Height { + get => _height; + set => SetSize(Width, value); + } + + /// + /// Internal reference for . + /// + protected volatile uint _maximumHeight; + + /// + public virtual uint MaximumHeight { + get => _maximumHeight; + set => SetSizeLimits(MinimumWidth, MinimumHeight, MaximumWidth, value); + } + + /// + /// Internal reference for . + /// + protected volatile WindowFullscreenMode _fullscreenMode; + + /// + public virtual WindowFullscreenMode FullscreenMode { + get => _fullscreenMode; + set => SetFullscreen(value); + } + + /// + /// Internal reference for . + /// + protected volatile bool _isResizable; + + /// + public virtual bool IsResizable => _isResizable; + + /// + /// Internal reference for . + /// + protected volatile bool _isDecorated; + + /// + public virtual bool IsDecorated => _isDecorated; + + /// + /// Internal reference for . + /// + protected volatile bool _isFocused; + + /// + public virtual bool IsFocused => _isFocused; + + /// + public virtual bool IsUnfocused => !_isFocused; + + /// + /// Internal reference for . + /// + protected volatile bool _isMinimized; + + /// + public virtual bool IsMinimized => _isMinimized; + + /// + /// Internal reference for . + /// + protected volatile bool _isMaximized; + + /// + public virtual bool IsMaximized => _isMaximized; + + /// + /// Internal reference for . + /// + protected volatile bool _isVisible; + + /// + public virtual bool IsVisible => _isVisible; + + /// + public virtual bool IsHidden => !_isVisible; + + /// + public virtual bool IsClosed => _disposed; + + /// + /// Used to cache the list of known displays. + /// + protected SdlDisplay[] _cachedDisplays; + + /// + /// Used to cache the primary display. + /// + protected SdlDisplay? _cachedPrimaryDisplay; + + /// + /// Used to signal polling resets. + /// + protected readonly ManualResetEvent _resetPollEventHandle; + + /// + /// Used to track the current state of the poll event handle. + /// + protected bool _resetPollEventHandleState; + + // Cached delegates for callbacks for performance +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + protected readonly ThreadDelegateDispatcher.DispatcherEventHandler _handlePreExecute; + protected readonly ThreadDelegateDispatcher.DispatcherQueueEventHandler _handleDelegateEnqueued; +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// A flag indicating whether the object has been disposed of. + /// + private bool _disposed; + + /// + /// A lock used to ensure thread-safe access to the object. + /// + private readonly ReaderWriterLockSlim _lock; + + /// + /// Constructs a new with + /// the specified options. + /// + /// The options to use when creating the window. + public SdlWindow(WindowOptions options) { + // Fields + _nativeHandle = []; + _pollRate = options.PollRate; + _display = null; + _title = options.Title; + _x = 0; + _y = 0; + _minimumWidth = uint.MinValue; + _width = options.Width; + _maximumWidth = uint.MaxValue; + _minimumHeight = uint.MinValue; + _height = options.Height; + _maximumHeight = uint.MaxValue; + _fullscreenMode = options.FullscreenMode; + _isResizable = options.Resizable; + _isDecorated = options.Decorated; + _isFocused = false; + _isMinimized = false; + _isMaximized = false; + _isVisible = !options.Hidden; + _cachedDisplays = []; + _cachedPrimaryDisplay = null; + _handlePreExecute = HandlePreExecute; + _handleDelegateEnqueued = HandleDelegateEnqueued; + _resetPollEventHandle = new(false); + _resetPollEventHandleState = false; + _disposed = false; + _lock = new(LockRecursionPolicy.SupportsRecursion); + + // Properties + Sdl = null!; + SdlHandle = nint.Zero; + SdlId = 0; + if (options.Dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) { + Sdl2.DebugContext.Log(LogLevel.Error, $"A main-thread dispatcher is required to create a {nameof(SdlWindow)} when no dispatcher is provided.", prefix: LogId); + throw new RequiresMainThreadException(nameof(SdlWindow), "constructor"); + } + Dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } else { + Dispatcher = options.Dispatcher; + } + + // Wait for other processes if they are initializing + using Mutex mutex = new(true, "Global\\CatalystUI_Sdl2_Lock", out bool newMutex); + if (!newMutex) { + if (!mutex.WaitOne(ThreadDelegateDispatcher.LockoutTimeout)) { + throw new WindowException("Failed to acquire mutex lock for Sdl2 window initialization."); + } + } + try { + // Perform window initialization + if (!Dispatcher.Execute(() => { + // Request an Sdl2 API instance + Sdl2.DebugContext.Log(LogLevel.Verbose, "Requesting Sdl2 instance..."); + Sdl = Sdl2.GetInstance(Dispatcher); + Sdl sdl = Sdl.Api; + Sdl2.DebugContext.Log(LogLevel.Verbose, "Done."); + + // Assign cached displays to avoid multiple creations/destruction of the API + _cachedPrimaryDisplay = Sdl2WindowLayer._cachedFunctionGetPrimaryDisplayUnsafe(Dispatcher); + _cachedDisplays = Sdl2WindowLayer._cachedFunctionGetDisplaysUnsafe(Dispatcher); + + // Specify initial visibility + // TODO: Wayland (Linux) check. + WindowFlags flags = WindowFlags.None; + if (!options.Hidden) flags |= WindowFlags.Hidden; + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Setting {WindowFlags.Hidden} window hint to {!options.Hidden}"); + if (options.Resizable) flags |= WindowFlags.Resizable; + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Setting {WindowFlags.Resizable} window hint to {options.Resizable}"); + if (!options.Decorated) flags |= WindowFlags.Borderless; + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Setting {WindowFlags.Borderless} window hint to {!options.Decorated}"); + + // Fire the initialization handler + options.InitializedHandler?.Invoke(this); + + // Create the window + Sdl2.DebugContext.Log(LogLevel.Verbose, "Creating the window..."); + byte* titlePtr = (byte*) SilkMarshal.StringToPtr(_title); + Window* handle = sdl.CreateWindow(titlePtr, Silk.NET.SDL.Sdl.WindowposUndefined, Silk.NET.SDL.Sdl.WindowposUndefined, (int) _width, (int) _height, (uint) flags); + SilkMarshal.Free((nint) titlePtr); + SdlHandle = (nint) handle; + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Sdl2 window created with {nameof(SdlHandle)} 0x{SdlHandle:X}."); + + // Fetch the Window ID + SdlId = sdl.GetWindowID((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Sdl2 window ID determined: {SdlId}."); + + // Get the native handle(s) + SysWMInfo wmInfo = default; + sdl.GetVersion(ref wmInfo.Version); + if (!sdl.GetWindowWMInfo((Window*) SdlHandle, &wmInfo)) { + Sdl2.DebugContext.Log(LogLevel.Warning, "Failed to get native window handle information from Sdl2. Native windowing functionality will be limited, and renderer compatibility may be null.", prefix: LogId); + } else { + if (SystemDetector.IsSystem()) { + _nativeHandle = [ wmInfo.Info.Win.Hwnd, wmInfo.Info.Win.HInstance, wmInfo.Info.Win.HDC ]; + } else if (SystemDetector.IsSystem()) { + _nativeHandle = [ (nint) wmInfo.Info.Cocoa.Window ]; + } else if (SystemDetector.IsSystem()) { + if (wmInfo.Subsystem == SysWMType.X11) { + _nativeHandle = [ (nint) wmInfo.Info.X11.Window, (nint) wmInfo.Info.X11.Display ]; + } else if (wmInfo.Subsystem == SysWMType.Wayland) { + _nativeHandle = [ (nint) wmInfo.Info.Wayland.Surface, (nint) wmInfo.Info.Wayland.Display ]; + } else { + Sdl2.DebugContext.Log(LogLevel.Warning, "Unknown Linux windowing subsystem detected. Native windowing functionality will be limited, and renderer compatibility may be null.", prefix: LogId); + } + } else { + Sdl2.DebugContext.Log(LogLevel.Warning, "No native handle found for Sdl2. Native windowing functionality will be limited, and renderer compatibility may be null.", prefix: LogId); + } + } + + // Set initial window size limits + SetSizeLimits(_minimumWidth, _minimumHeight, _maximumWidth, _maximumHeight); + + // Set initial fullscreen mode + //SetFullscreen(_fullscreenMode); + + // Set window icons + //if (options.Icons is { Length: > 0 }) SetIcons(options.Icons); + + // Fire created + OnCreated(); + + // Attach dispatcher event loop + Sdl2.DebugContext.Log(LogLevel.Verbose, "Starting event loop...", prefix: LogId); + Dispatcher.PreExecute += _handlePreExecute; + Dispatcher.DelegateEnqueued += _handleDelegateEnqueued; + }, wait: true, timeout: Timeout.Infinite)) { + throw new WindowException("Failed to initialize the window."); + } + } finally { + // Release the mutex + mutex.ReleaseMutex(); + } + } + + /// + /// Disposes of the . + /// + ~SdlWindow() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: false); + } + + /// + public virtual void SetPosition(double x, double y) { + // TODO: Wayland (Linux) check. + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.SetWindowPosition((Window*) SdlHandle, (int) x, (int) y); + Sdl2.DebugContext.Log(LogLevel.Debug, $"Window position set to ({x}, {y}).", prefix: LogId); + }, wait: true); + } + + /// + public virtual void SetSize(uint width, uint height) { + // TODO: Wayland (Linux) check. + ObjectDisposedException.ThrowIf(_disposed, this); + if (width == 0) width = IWindow.DEFAULT_WIDTH; + if (height == 0) height = IWindow.DEFAULT_HEIGHT; + _ = Dispatcher.Execute(() => { + Sdl.Api.SetWindowSize((Window*) SdlHandle, (int) width, (int) height); + Sdl2.DebugContext.Log(LogLevel.Debug, $"Window size set to {width}x{height}.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void SetSizeLimits(uint minWidth, uint minHeight, uint maxWidth, uint maxHeight) { + ObjectDisposedException.ThrowIf(_disposed, this); + if (minWidth == 0) minWidth = uint.MinValue; + if (minHeight == 0) minHeight = uint.MinValue; + if (maxWidth == 0) maxWidth = uint.MaxValue; + if (maxHeight == 0) maxHeight = uint.MaxValue; + _ = Dispatcher.Execute(() => { + Sdl.Api.SetWindowMinimumSize((Window*) SdlHandle, (int) minWidth, (int) minHeight); + Sdl.Api.SetWindowMaximumSize((Window*) SdlHandle, (int) maxWidth, (int) maxHeight); + Sdl2.DebugContext.Log(LogLevel.Debug, $"Window size limits set to {(_minimumWidth == uint.MinValue ? "Unlimited" : _minimumWidth)}x{(_minimumHeight == uint.MinValue ? "Unlimited" : _minimumHeight)} - {(_maximumWidth == uint.MaxValue ? "Unlimited" : _maximumWidth)}x{(_maximumHeight == uint.MaxValue ? "Unlimited" : _maximumHeight)}.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void SetFullscreen(WindowFullscreenMode mode, IDisplay? display = null, uint width = 0, uint height = 0) { + throw new NotImplementedException(); + } + + /// + public virtual void SetIcons(params WindowIcon[] icons) { + throw new NotImplementedException(); + } + + /// + public virtual void RequestFocus() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.RaiseWindow((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window focus requested.", prefix: LogId); + }, wait: true); + } + + /// The flash operation to perform. + /// + public virtual void RequestAttention(FlashOperation operation) { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.FlashWindow((Window*) SdlHandle, operation); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window attention requested.", prefix: LogId); + }, wait: true); + } + + + /// + public virtual void RequestAttention() { + RequestAttention(FlashOperation.UntilFocused); + } + + /// + public virtual void Minimize() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.MinimizeWindow((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window minimized.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void Maximize() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.MaximizeWindow((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window maximized.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void Restore() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.RestoreWindow((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window restored.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void Show() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.ShowWindow((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window shown.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void Hide() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Sdl.Api.HideWindow((Window*) SdlHandle); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window hidden.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void Close() { + throw new NotImplementedException(); + } + + /// + public virtual void Exit() { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Dispose(); + OnClosed(); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window exited.", prefix: LogId); + }, wait: true); + } + + /// + public virtual void Wait() { + if (_disposed) return; + ManualResetEvent reset = new(false); + try { + Closed += _ => { + // ReSharper disable once AccessToDisposedClosure + reset.Set(); + }; + reset.WaitOne(); + } finally { + reset.Dispose(); + } + } + + /// + /// Refreshes the backing fields for the window properties. + /// + /// + /// Since Glfw3 does not provide events for all window property changes, + /// the following method is provided to manually refresh all properties + /// from the underlying Glfw3 window state. It should be called whenever + /// a property change is suspected that may not have triggered an event. + /// + protected virtual void RefreshProperties() { + // TODO: Wayland (Linux) check. + // Pull position + int x = 0; + int y = 0; + Sdl.Api.GetWindowPosition((Window*) SdlHandle, ref x, ref y); + _x = x; + _y = y; + + // Pull size + int width = 0; + int height = 0; + Sdl.Api.GetWindowSize((Window*) SdlHandle, ref width, ref height); + _width = (uint) width; + _height = (uint) height; + + // Pull state + WindowFlags flags = (WindowFlags) Sdl.Api.GetWindowFlags((Window*) SdlHandle); + _isFocused = (flags & WindowFlags.InputFocus) != 0; + _isMinimized = (flags & WindowFlags.Minimized) != 0; + _isMaximized = (flags & WindowFlags.Maximized) != 0; + _isVisible = (flags & WindowFlags.Shown) != 0; + + // Refresh the display + RefreshDisplay(); + + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Refreshed on-request properties: Pos({_x}, {_y}), Size({_width}x{_height}), Focused({_isFocused}), Minimized({_isMinimized}), Maximized({_isMaximized}), Visible({_isVisible}), Display({_display})", prefix: LogId); + // The rest of internal variables are state-driven and requested on-demand. + + // Fire refresh + OnRefresh(); + } + + /// + /// Refreshes the property by + /// determining which display the window is currently on. + /// + protected virtual void RefreshDisplay() { + if (_cachedDisplays.Length == 0) return; + + // Determine which monitor has the most overlap + double bestOverlap = 0.0f; + SdlDisplay? bestDisplay = null; + for (int i = 0; i < _cachedDisplays.Length; i++) { + double wx = _x; + double wy = _y; + uint ww = _width; + uint wh = _height; + double dx = _cachedDisplays[i].X; + double dy = _cachedDisplays[i].Y; + uint dw = _cachedDisplays[i].Width; + uint dh = _cachedDisplays[i].Height; + + // Determine overlap + double overlap = + (uint) Math.Max(0, Math.Min(wx + ww, dx + (int) dw) - Math.Max(wx, dx)) * + (double) Math.Max(0, Math.Min(wy + wh, dy + (int) dh) - Math.Max(wy, dy)); + + // Determine best overlap + if (overlap > bestOverlap) { + bestOverlap = overlap; + bestDisplay = _cachedDisplays[i]; + } + } + + // Assign the best display + _display = bestDisplay; + + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Refreshed display. Determined best display: {_display}", prefix: LogId); + } + + /// + protected virtual void HandlePreExecute(ThreadDelegateDispatcher dispatcher) { + // Nullability check due to race conditions during disposal + if (Sdl?.Api == null) return; + Event @event = default; + if (PollRate == 0) { + // Passive polling (only when events are enqueued) + while (!_disposed && dispatcher.Enqueued == 0) { + Sdl.Api.WaitEventTimeout(ref @event, MINIMUM_POLL_RATE); + HandleEvent(@event); + } + } else if (PollRate != ushort.MaxValue) { + // Active polling (on X intervals of time) + if (Sdl.Api.PollEvent(ref @event) == 1) HandleEvent(@event); + _resetPollEventHandle.WaitOne(PollRate); + if (_resetPollEventHandleState) { + _resetPollEventHandle.Reset(); + _resetPollEventHandleState = false; + } + } else { + // Fastest polling (on every loop of the main thread) + if (Sdl.Api.PollEvent(ref @event) == 1) HandleEvent(@event); + } + } + + /// + protected virtual void HandleDelegateEnqueued(ThreadDelegateDispatcher dispatcher, Delegate @delegate) { + Event dummyEvent = new() { + Type = (uint) EventType.Userevent, + User = new() { + Type = (uint) EventType.Userevent, + Code = 0x01, + Data1 = (void*) 0, + Data2 = (void*) 0 + } + }; + Sdl.Api.PushEvent(&dummyEvent); // un-block the main thread if waiting + _resetPollEventHandle.Set(); + _resetPollEventHandleState = true; + } + + /// + /// Handles an event received from Sdl2. + /// + /// The event to handle. + protected virtual void HandleEvent(Event evt) { + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Handling Sdl2 event of type {(EventType) evt.Type}.", prefix: LogId); + if (evt.Type == (uint) EventType.Displayevent) { + DisplayEvent disevt = evt.Display; + if (disevt.Event == (byte) DisplayEventID.Moved || disevt.Event == (byte) DisplayEventID.Orientation) { + RefreshDisplay(); + } else if (disevt.Event == (byte) DisplayEventID.Connected || disevt.Event == (byte) DisplayEventID.Disconnected) { + // Update cached displays + _cachedPrimaryDisplay = Sdl2WindowLayer._cachedFunctionGetPrimaryDisplayUnsafe(Dispatcher); + _cachedDisplays = Sdl2WindowLayer._cachedFunctionGetDisplaysUnsafe(Dispatcher); + } + } else if (evt.Type == (uint) EventType.Windowevent) { + WindowEvent winevt = evt.Window; + if (winevt.WindowID != SdlId) return; // it's not for us + if (winevt.Event == (byte) WindowEventID.Shown) { + OnShown(); + } else if (winevt.Event == (byte) WindowEventID.Hidden) { + OnHidden(); + } else if (winevt.Event == (byte) WindowEventID.Exposed) { + RefreshProperties(); + OnRedraw(); + } else if (winevt.Event == (byte) WindowEventID.Moved) { + RefreshProperties(); + OnRepositioned(); + } else if (winevt.Event is (byte) WindowEventID.Resized or (byte) WindowEventID.SizeChanged) { // but why are there two events for this? + OnResized(); + } else if (winevt.Event == (byte) WindowEventID.Minimized) { + RefreshProperties(); + OnMinimized(); + } else if (winevt.Event == (byte) WindowEventID.Maximized) { + RefreshProperties(); + OnMaximized(); + } else if (winevt.Event == (byte) WindowEventID.Restored) { + RefreshProperties(); + OnRestored(); + } else if (winevt.Event == (byte) WindowEventID.FocusGained) { + RefreshProperties(); + OnFocused(); + } else if (winevt.Event == (byte) WindowEventID.FocusLost) { + RefreshProperties(); + OnUnfocused(); + } else if (winevt.Event == (byte) WindowEventID.Close) { + bool shouldCancel = false; + _ = Dispatcher.Execute(() => { + shouldCancel = OnClosing(); + }, wait: true); + if (shouldCancel) return; + Dispose(); + OnClosed(); + } + } else if (evt.Type == (uint) EventType.Quit) { + bool shouldCancel = false; + _ = Dispatcher.Execute(() => { + shouldCancel = OnClosing(); + }, wait: true); + if (shouldCancel) return; + Dispose(); + OnClosed(); + } + } + + /// + protected virtual void OnErrored(WindowException exception) { + Sdl2.DebugContext.Log(LogLevel.Error, "Window exception occurred!", prefix: LogId, args: exception); + Errored?.Invoke(this, exception); + } + + /// + protected virtual void OnCreated() { + Sdl2.DebugContext.Log(LogLevel.Info, "Window created.", prefix: LogId); + Created?.Invoke(this); + } + + /// + protected virtual void OnRepositioned() { + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Window repositioned to ({X}, {Y}).", prefix: LogId); + Repositioned?.Invoke(this); + } + + /// + protected virtual void OnResized() { + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Window resized to {Width}x{Height}.", prefix: LogId); + Resized?.Invoke(this); + } + + /// + protected virtual void OnRefresh() { + Refresh?.Invoke(this); + } + + /// + protected virtual void OnRedraw() { + Redraw?.Invoke(this); + } + + /// + protected virtual void OnFocused() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window focused.", prefix: LogId); + Focused?.Invoke(this); + } + + /// + protected virtual void OnUnfocused() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window unfocused.", prefix: LogId); + Unfocused?.Invoke(this); + } + + /// + protected virtual void OnMinimized() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window minimized.", prefix: LogId); + Minimized?.Invoke(this); + } + + /// + protected virtual void OnMaximized() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window maximized.", prefix: LogId); + Maximized?.Invoke(this); + } + + /// + protected virtual void OnRestored() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window restored.", prefix: LogId); + Restored?.Invoke(this); + } + + /// + protected virtual void OnShown() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window shown.", prefix: LogId); + Shown?.Invoke(this); + } + + /// + protected virtual void OnHidden() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window hidden.", prefix: LogId); + Hidden?.Invoke(this); + } + + /// + protected virtual bool OnClosing() { + Sdl2.DebugContext.Log(LogLevel.Debug, "Window closing...", prefix: LogId); + Delegate[] delegates = Closing?.GetInvocationList() ?? []; + bool result = delegates.Cast().Any(handler => handler(this)); + if (result) Sdl2.DebugContext.Log(LogLevel.Debug, "Window closure cancelled by event handler.", prefix: LogId); + return result; + } + + /// + protected virtual void OnClosed() { + Sdl2.DebugContext.Log(LogLevel.Info, "Window closed.", prefix: LogId); + Closed?.Invoke(this); + } + + /// + protected virtual void OnInteracted() { + throw new NotImplementedException(); + } + + /// + /// Disposes of the . + /// + public virtual void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// if disposal is being performed by the garbage collector, otherwise + /// + private void Dispose(bool disposing) { + _lock.EnterWriteLock(); + try { + if (_disposed) return; + + // Dispose managed state (managed objects) + if (disposing) { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (Dispatcher != null) { + Dispatcher.PreExecute -= _handlePreExecute; + Dispatcher.DelegateEnqueued -= _handleDelegateEnqueued; + } + } + + // Dispose unmanaged state (unmanaged objects) + // ... + + // Indicate disposal completion + _disposed = true; + } finally { + _lock.ExitWriteLock(); + } + } + + } + +} \ No newline at end of file From 848f97f32601c93d4727e0417df4af14e5be81ec Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Sat, 8 Nov 2025 23:02:37 -0700 Subject: [PATCH 03/14] Update CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: FireController#1847 --- .../CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs index 190e00f..4d393ff 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs @@ -414,8 +414,8 @@ public SdlWindow(WindowOptions options) { // Specify initial visibility // TODO: Wayland (Linux) check. WindowFlags flags = WindowFlags.None; - if (!options.Hidden) flags |= WindowFlags.Hidden; - Sdl2.DebugContext.Log(LogLevel.Verbose, $"Setting {WindowFlags.Hidden} window hint to {!options.Hidden}"); + if (options.Hidden) flags |= WindowFlags.Hidden; + Sdl2.DebugContext.Log(LogLevel.Verbose, $"Setting {WindowFlags.Hidden} window hint to {options.Hidden}"); if (options.Resizable) flags |= WindowFlags.Resizable; Sdl2.DebugContext.Log(LogLevel.Verbose, $"Setting {WindowFlags.Resizable} window hint to {options.Resizable}"); if (!options.Decorated) flags |= WindowFlags.Borderless; From dae85607aa3ad611dfc3a12c069d0bd0e81191b7 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Mon, 10 Nov 2025 17:53:29 -0700 Subject: [PATCH 04/14] Resolve exception recursive episodes * Resolved exceptions from the DelegateQueue causing a recursive loop when attempting to unwind the stack. * Resolved exceptions from the worker thread in CatalystApp from being consumed. --- .../Core/CatalystUI.Core/CatalystApp.cs | 11 ++- .../CatalystUI.Threading/DelegateQueue.cs | 71 +++++++++++-------- .../ThreadDelegateDispatcher.cs | 46 +++++++++++- 3 files changed, 93 insertions(+), 35 deletions(-) diff --git a/CatalystUI/Core/CatalystUI.Core/CatalystApp.cs b/CatalystUI/Core/CatalystUI.Core/CatalystApp.cs index ae5d624..a3e9a66 100644 --- a/CatalystUI/Core/CatalystUI.Core/CatalystApp.cs +++ b/CatalystUI/Core/CatalystUI.Core/CatalystApp.cs @@ -13,6 +13,7 @@ using Catalyst.Debugging; using Catalyst.Threading; using System; +using System.Runtime.ExceptionServices; using System.Threading; namespace Catalyst { @@ -67,6 +68,7 @@ internal CatalystApp(Func? runMethod = null) { // Properties _debug.Log(LogLevel.Verbose, "Capturing main thread dispatcher and running application..."); + ExceptionDispatchInfo? exception = null; ThreadDelegateDispatcher.Capture(dispatcher => { // Assign the main-thread dispatcher to the app. Dispatcher = dispatcher; @@ -77,13 +79,16 @@ internal CatalystApp(Func? runMethod = null) { if (runMethod != null) { _debug.Log(LogLevel.Info, "CatalystApp initialized, initializing worker thread. Application will run until the provided run method exits."); ThreadDelegateDispatcher workerThread = ThreadDelegateDispatcher.New("CallerThread"); - workerThread.Execute(() => { - Exit(runMethod(this)); - }); + workerThread.DelegateException += (_, _, e) => { + exception = e; + dispatcher.Dispose(); + }; + workerThread.Execute(() => Exit(runMethod(this))); } else { _debug.Log(LogLevel.Info, "CatalystApp initialized. No run method provided, application will run until exit."); } }, isMainThread: true); + exception?.Throw(); // Set the exit code and let the application return gracefully. Environment.ExitCode = _exitCode; diff --git a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs index f7ba7b0..4565b38 100644 --- a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs +++ b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs @@ -28,38 +28,35 @@ public sealed class DelegateQueue : IDisposable { public delegate void DelegateQueueEventHandler(DelegateQueue queue, EnqueuedDelegate @delegate); /// - /// Invoked when a delegate is enqueued but prior to its execution. + /// An event handler delegate for exception events related to the . + /// + public delegate void DelegateQueueExceptionEventHandler(DelegateQueue queue, EnqueuedDelegate @delegate, ExceptionDispatchInfo? exception); + + /// + /// Invoked when a delegate is enqueued. /// - /// - /// Invoked on the calling thread, not the executing thread. - ///

- /// During invocation, the queue lock is held, so avoid long-running - /// operations or deadlocks may occur. - ///
public event DelegateQueueEventHandler? DelegateEnqueued; /// - /// Invoked when a delegate is dequeued for execution but prior to its execution. + /// Invoked when a delegate is dequeued prior to execution. /// - /// - /// Invoked on the executing thread, not the calling thread. - ///

- /// During invocation, the queue lock is held, so avoid long-running - /// operations or deadlocks may occur. - ///
public event DelegateQueueEventHandler? DelegateDequeued; /// - /// Invoked after a delegate has been executed. + /// Invoked when a delegate has been executed successfully. /// - /// - /// Invoked on the executing thread, not the calling thread. - ///

- /// During invocation, the queue lock is held, so avoid long-running - /// operations or deadlocks may occur. - ///
public event DelegateQueueEventHandler? DelegateExecuted; + /// + /// Invoked when a delegate throws an exception during execution. + /// + public event DelegateQueueExceptionEventHandler? DelegateException; + + /// + /// Invoked when a delegate has finished execution, regardless of success or failure. + /// + public event DelegateQueueEventHandler? DelegateFinished; + /// /// The queue which is used to store enqueued delegates. /// @@ -189,7 +186,7 @@ public bool Enqueue(Delegate @delegate, nint caller, nint parameters, out nint @ entry.WaitHandle!.WaitOne(); // wait indefinitely } @return = entry.Return; - if (entry.Exception != null) ExceptionDispatchInfo.Capture(entry.Exception).Throw(); + entry.Exception?.Throw(); } else { OnDelegateEnqueued(entry); _lock.Exit(); // exit the lock @@ -214,11 +211,17 @@ public void Execute() { ref EnqueuedDelegate entry = ref _queue.PeekDequeue(); OnDelegateDequeued(entry); _lock.Exit(); // leave the lock for delegate execution + bool success = false; try { entry.Execute(); + success = true; + } catch { + success = false; } finally { _lock.Enter(); // re-enter the lock after execution - OnDelegateExecuted(entry); + if (success) OnDelegateExecuted(entry); + else OnDelegateException(entry, entry.Exception); + OnDelegateFinished(entry); // if we disposed of ourselves or the queue while executing, // it could have become empty, so we check again if (!_disposed && !_queue.IsEmpty) { @@ -252,6 +255,16 @@ private void OnDelegateExecuted(EnqueuedDelegate @delegate) { DelegateExecuted?.Invoke(this, @delegate); } + /// + private void OnDelegateException(EnqueuedDelegate @delegate, ExceptionDispatchInfo? exception) { + DelegateException?.Invoke(this, @delegate, exception); + } + + /// + private void OnDelegateFinished(EnqueuedDelegate @delegate) { + DelegateFinished?.Invoke(this, @delegate); + } + /// /// Disposes of the . /// @@ -325,7 +338,7 @@ public record struct EnqueuedDelegate { /// Gets or sets an exception that occurred during execution of the delegate, if any. ///
/// The exception that occurred during execution, or if no exception occurred. - public required Exception? Exception { get; set; } + public required ExceptionDispatchInfo? Exception { get; set; } /// /// Gets or sets a wait handle that can be used to wait for the delegate to complete execution. @@ -367,13 +380,9 @@ public void Execute() { throw new NotSupportedException($"The delegate type {Delegate.GetType()} is not supported by the {nameof(EnqueuedDelegate)} structure."); } } catch (Exception e) { - // If we have an awaiter, pass the exception to it - // Otherwise, it's an unhandled exception - if (HasAwaiter) { - Exception = e; - } else { - throw; - } + // Capture the exception dispatch info, then throw if it's unhandled + Exception = ExceptionDispatchInfo.Capture(e); + if (!HasAwaiter) throw; } } diff --git a/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs b/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs index 22925bc..6d7fb4d 100644 --- a/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs +++ b/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs @@ -13,6 +13,7 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; using System.Threading; namespace Catalyst.Threading { @@ -32,6 +33,11 @@ public sealed class ThreadDelegateDispatcher : IDisposable { /// public delegate void DispatcherQueueEventHandler(ThreadDelegateDispatcher dispatcher, Delegate @delegate); + /// + /// An event handler delegate for exception events related to the . + /// + public delegate void DispatcherQueueExceptionEventHandler(ThreadDelegateDispatcher dispatcher, Delegate @delegate, ExceptionDispatchInfo? exception); + /// /// Invoked prior to the execution of the delegate queue. /// @@ -57,6 +63,12 @@ public sealed class ThreadDelegateDispatcher : IDisposable { /// public event DispatcherQueueEventHandler? DelegateExecuted; + /// + public event DispatcherQueueExceptionEventHandler? DelegateException; + + /// + public event DispatcherQueueEventHandler? DelegateFinished; + /// /// The default sizing for the delegate caches when using the typed execute methods. /// @@ -141,6 +153,8 @@ private ThreadDelegateDispatcher(Thread thread, int threadId, int? queueSize = n _queue.DelegateEnqueued += HandleDelegateEnqueued; _queue.DelegateDequeued += HandleDelegateDequeued; _queue.DelegateExecuted += HandleDelegateExecuted; + _queue.DelegateException += HandleDelegateException; + _queue.DelegateFinished += HandleDelegateFinished; _disposed = false; _lock = new(); @@ -225,8 +239,16 @@ public static void Capture(Action callback, int? queue ); if (isMainThread) MainThreadDispatcher = dispatcher; // manually enqueue to prevent our smart thread id checking from just calling it directly - dispatcher._queue.Enqueue(() => callback(dispatcher), nint.Zero, nint.Zero, out _); + ExceptionDispatchInfo? exception = null; + dispatcher._queue.Enqueue(() => { + try { + callback(dispatcher); + } catch (Exception e) { + exception = ExceptionDispatchInfo.Capture(e); + } + }, nint.Zero, nint.Zero, out _); dispatcher.WorkerThread(); + exception?.Throw(); } /// @@ -537,6 +559,16 @@ private void HandleDelegateExecuted(DelegateQueue queue, DelegateQueue.EnqueuedD OnDelegateExecuted(@delegate.Delegate); } + /// + private void HandleDelegateException(DelegateQueue queue, DelegateQueue.EnqueuedDelegate @delegate, ExceptionDispatchInfo? exception) { + OnDelegateException(@delegate.Delegate, exception); + } + + /// + private void HandleDelegateFinished(DelegateQueue queue, DelegateQueue.EnqueuedDelegate @delegate) { + OnDelegateFinished(@delegate.Delegate); + } + /// private void OnPreExecute() { PreExecute?.Invoke(this); @@ -562,6 +594,16 @@ private void OnDelegateExecuted(Delegate @delegate) { DelegateExecuted?.Invoke(this, @delegate); } + /// + private void OnDelegateException(Delegate @delegate, ExceptionDispatchInfo? exception) { + DelegateException?.Invoke(this, @delegate, exception); + } + + /// + private void OnDelegateFinished(Delegate @delegate) { + DelegateFinished?.Invoke(this, @delegate); + } + /// /// The worker thread method that processes the delegate queue. /// @@ -602,6 +644,8 @@ private void Dispose(bool disposing) { _queue.DelegateEnqueued -= HandleDelegateEnqueued; _queue.DelegateDequeued -= HandleDelegateDequeued; _queue.DelegateExecuted -= HandleDelegateExecuted; + _queue.DelegateException -= HandleDelegateException; + _queue.DelegateFinished -= HandleDelegateFinished; _queue.Dispose(); } From 36aea63556df549225b53a177253ab651d0f6d8e Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Mon, 10 Nov 2025 18:02:03 -0700 Subject: [PATCH 05/14] Add the OpenGL project, various fixes * Created a new Catalyst.Modules.Crystal.OpenGL project. * Resolved naming inconsistencies in the builder extensions for Glfw3. * Added an implementation of OpenGLMacNativeConnector. * Added an implementation of the OpenGL INativeApi wrapper. --- .scripts/Setup.ps1 | 10 +- CatalystUI/CatalystUI.sln | 7 + .../CatalystAppBuilderExtensions.cs | 8 +- .../CatalystUI.Modules.Crystal.Glfw3/Glfw3.cs | 6 +- .../Glfw3MacNativeConnector.cs | 2 +- .../Glfw3WindowsNativeConnector.cs | 2 +- .../CatalystUI.Modules.Crystal.OpenGL.csproj | 28 +++ .../CatalystAppBuilderExtensions.cs | 57 +++++ .../IOpenGLNativeConnector.cs | 72 ++++++ .../OpenGLMacNativeConnector.cs | 150 +++++++++++++ .../OpenGL.cs | 207 ++++++++++++++++++ .../OpenGLRendererLayer.cs | 24 ++ 12 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Extensions/CatalystAppBuilderExtensions.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGL.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs diff --git a/.scripts/Setup.ps1 b/.scripts/Setup.ps1 index 66d11ef..3eecb92 100644 --- a/.scripts/Setup.ps1 +++ b/.scripts/Setup.ps1 @@ -95,7 +95,15 @@ $projectsList = @( ) PromptIgnore = $false Depends = @("Crystal") - } + }, + @{ + Module = "Crystal.OpenGL" + Projects = @( + @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.OpenGL" } + ) + PromptIgnore = $false + Depends = @("Crystal") + }, ) # Build prompt options diff --git a/CatalystUI/CatalystUI.sln b/CatalystUI/CatalystUI.sln index aea7293..a371292 100644 --- a/CatalystUI/CatalystUI.sln +++ b/CatalystUI/CatalystUI.sln @@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.Sdl2", "Modules\Crystal\CatalystUI.Modules.Crystal.Sdl2\CatalystUI.Modules.Crystal.Sdl2.csproj", "{532C59A0-43D2-44E9-B6ED-E88E6B8770F5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.OpenGL", "Modules\Crystal\CatalystUI.Modules.Crystal.OpenGL\CatalystUI.Modules.Crystal.OpenGL.csproj", "{9CD307E2-FEDA-4C64-B2EE-00E6E196175C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -108,6 +110,10 @@ Global {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Debug|Any CPU.Build.0 = Debug|Any CPU {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Release|Any CPU.ActiveCfg = Release|Any CPU {532C59A0-43D2-44E9-B6ED-E88E6B8770F5}.Release|Any CPU.Build.0 = Release|Any CPU + {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {68F496AC-9438-40F1-9DF8-97363033D661} = {7EC51871-49A8-4991-BDAF-F43A4E9B8C9D} @@ -128,5 +134,6 @@ Global {4DE1A1EB-103B-4E07-859A-354908ACE0E2} = {41BEF490-7005-4D10-9958-22D636F9DE38} {BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18} = {41BEF490-7005-4D10-9958-22D636F9DE38} {532C59A0-43D2-44E9-B6ED-E88E6B8770F5} = {41BEF490-7005-4D10-9958-22D636F9DE38} + {9CD307E2-FEDA-4C64-B2EE-00E6E196175C} = {41BEF490-7005-4D10-9958-22D636F9DE38} EndGlobalSection EndGlobal diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Extensions/CatalystAppBuilderExtensions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Extensions/CatalystAppBuilderExtensions.cs index 33a1a74..5cef707 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Extensions/CatalystAppBuilderExtensions.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Extensions/CatalystAppBuilderExtensions.cs @@ -40,11 +40,11 @@ public static CatalystAppBuilder AddCrystalGlfw3Module(this CatalystAppBuilder b Glfw3WindowLayer glfw3WindowingLayer = new(); ModelRegistry.RegisterLayer(glfw3WindowingLayer); if (SystemDetector.IsSystem()) { - Glfw3WindowsNativeHandler windowsHandler = new(); - ModelRegistry.RegisterConnector(windowsHandler); + Glfw3WindowsNativeConnector windowsConnector = new(); + ModelRegistry.RegisterConnector(windowsConnector); } else if (SystemDetector.IsSystem()) { - Glfw3MacNativeHandler macHandler = new(); - ModelRegistry.RegisterConnector(macHandler); + Glfw3MacNativeConnector macConnector = new(); + ModelRegistry.RegisterConnector(macConnector); } else if (SystemDetector.IsSystem()) { throw new NotImplementedException(); } else { diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Glfw3.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Glfw3.cs index 580adb0..a381f02 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Glfw3.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Glfw3.cs @@ -108,7 +108,7 @@ private Glfw3() { public static Glfw3 GetInstance(ThreadDelegateDispatcher? dispatcher = null) { _staticLock.Enter(); try { - if (_referenceCount == 0) Initialize(); + if (_referenceCount == 0) Initialize(dispatcher); _referenceCount++; return new(); } finally { @@ -126,7 +126,7 @@ private static void Initialize(ThreadDelegateDispatcher? dispatcher = null) { dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; } if (!dispatcher.Execute(_cachedActionInitializeUnsafe, wait: true)) { - throw new TypeInitializationException(nameof(Glfw3), new InvalidOperationException("Failed to initialize Glfw3 API on the main thread.")); + throw new TypeInitializationException(nameof(Glfw3), new InvalidOperationException("Failed to initialize Glfw3 API.")); } _initDispatcher = dispatcher; } @@ -147,7 +147,7 @@ private static void Terminate(ThreadDelegateDispatcher? dispatcher = null) { dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; } if (!dispatcher.Execute(_cachedActionTerminateUnsafe, wait: true)) { - throw new InvalidOperationException("Failed to terminate Glfw3 API on the main thread."); + throw new InvalidOperationException("Failed to terminate Glfw3 API."); } } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3MacNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3MacNativeConnector.cs index 1e430f1..6b2af4d 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3MacNativeConnector.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3MacNativeConnector.cs @@ -22,7 +22,7 @@ namespace Catalyst.Modules.Crystal.Glfw3 { /// /// An Apple Mac-based implementation of /// - public sealed unsafe class Glfw3MacNativeHandler : IGlfw3NativeConnector { + public sealed unsafe class Glfw3MacNativeConnector : IGlfw3NativeConnector { /// public nint GetNativeHandle(Glfw3 glfw, WindowHandle* pWindow) { diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3WindowsNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3WindowsNativeConnector.cs index fdfb9fe..a7d53a2 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3WindowsNativeConnector.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/NativeConnectors/Glfw3WindowsNativeConnector.cs @@ -23,7 +23,7 @@ namespace Catalyst.Modules.Crystal.Glfw3 { /// /// A Microsoft Windows-based implementation of . /// - public sealed unsafe class Glfw3WindowsNativeHandler : IGlfw3NativeConnector { + public sealed unsafe class Glfw3WindowsNativeConnector : IGlfw3NativeConnector { /// public nint GetNativeHandle(Glfw3 glfw, WindowHandle* pWindow) { diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj new file mode 100644 index 0000000..ed069c0 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj @@ -0,0 +1,28 @@ + + + + + + Catalyst.Modules.Crystal.OpenGL + Catalyst.Modules.Crystal.OpenGL + true + + + CatalystUI Crystal – OpenGL + 1.0.0 + alpha.1 + CatalystUI LLC + OpenGL API for the Crystal subset of modules provided by the CatalystUI library. + CatalystUI,Crystal,opengl,gl,ui,gui,graphics,visual,window + + + + + + + + + + + + \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Extensions/CatalystAppBuilderExtensions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Extensions/CatalystAppBuilderExtensions.cs new file mode 100644 index 0000000..067fe91 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Extensions/CatalystAppBuilderExtensions.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Modules.Crystal.OpenGL; +using Catalyst.Supplementary; +using System; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Builders.Extensions { + + /// + /// Builder extensions for the . + /// + public static class CatalystAppBuilderExtensions { + + /// + /// Adds the Crystal-based OpenGL rendering module to the . + /// + /// + /// + /// The Crystal-based OpenGL rendering module adds the following to your CatalystUI application: + /// + /// + /// + /// + /// Click on any of the above links to learn more about each component. + /// + /// + /// The to add the module to. + /// The with the OpenGL rendering module added. + public static CatalystAppBuilder AddCrystalOpenGLModule(this CatalystAppBuilder builder) { + OpenGLRendererLayer openGLRendererLayer = new(); + ModelRegistry.RegisterLayer(openGLRendererLayer); + if (SystemDetector.IsSystem()) { + throw new NotImplementedException(); + } else if (SystemDetector.IsSystem()) { + OpenGLMacNativeConnector macConnector = new(); + ModelRegistry.RegisterConnector(macConnector); + } else if (SystemDetector.IsSystem()) { + throw new NotImplementedException(); + } else { + throw new PlatformNotSupportedException("The Crystal OpenGL module is not supported on this platform. Consider using a different rendering module or contributing a native connector for this platform."); + } + return builder; + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs new file mode 100644 index 0000000..7012d20 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Connectors; +using Catalyst.Domains; +using Catalyst.Layers; + +namespace Catalyst.Modules.Crystal.OpenGL { + + /// + /// The Crystal interface of a native connector for the OpenGL rendering API. + /// + /// + public interface IOpenGLNativeConnector : INativeConnector where TLayerLow : ISystemLayer { + + /// + /// Gets the address of an OpenGL function by name. + /// + /// The name of the OpenGL function. + /// The address of the OpenGL function. + nint GetProcAddress(string name); + + /// + /// Creates an OpenGL context for the specified render target. + /// + /// The OpenGL instance. + /// The render target. + /// The handle of the created OpenGL context. + nint CreateContext(OpenGL gl, IRenderTarget target); + + /// + /// Attempts to delete the specified OpenGL context for the given render target. + /// + /// The handle of the OpenGL context to delete. + /// The render target. + /// if the context was successfully deleted; otherwise, . + bool TryDeleteContext(nint context, IRenderTarget target); + + /// + /// Attempts to make the specified OpenGL context current for the given render target. + /// + /// The handle of the OpenGL context to make current. + /// The render target. + /// if the context was successfully made current; otherwise, . + bool TryMakeContextCurrent(nint context, IRenderTarget target); + + /// + /// Swaps the front and back buffers of the specified + /// render target using the current OpenGL context. + /// + /// The render target. + /// if the buffers were successfully swapped; otherwise, . + bool SwapBuffers(IRenderTarget target); + + /// + /// Sets the swap interval for the current OpenGL context. + /// + /// 0 to disable V-Sync, 1 to enable V-Sync. + /// if the swap interval was successfully set; otherwise, . + bool SetSwapInterval(int interval); + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs new file mode 100644 index 0000000..efd15b3 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs @@ -0,0 +1,150 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Supplementary; +using System; +using System.Runtime.InteropServices; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Modules.Crystal.OpenGL { + + /// + /// An Apple Mac-based implementation of . + /// + public unsafe class OpenGLMacNativeConnector : IOpenGLNativeConnector { + + /// + public nint GetProcAddress(string name) { + nint proc = MacOpenGLImports.dlsym(MacOpenGLImports._glHandle, name); + if (proc == 0) proc = MacOpenGLImports.dlsym(MacOpenGLImports.RTLD_DEFAULT, name); + return proc; + } + + /// + public nint CreateContext(OpenGL gl, IRenderTarget target) { + // Fetch classes and selectors + nint fmtClass = MacOpenGLImports.objc_getClass("NSOpenGLPixelFormat"); + nint ctxClass = MacOpenGLImports.objc_getClass("NSOpenGLContext"); + nint allocSel = MacOpenGLImports.sel_registerName("alloc"); + nint initAttrSel = MacOpenGLImports.sel_registerName("initWithAttributes:"); + nint initFormatSel = MacOpenGLImports.sel_registerName("initWithFormat:shareContext:"); + nint makeCurrentSel = MacOpenGLImports.sel_registerName("makeCurrentContext"); + + // Attribute list for OpenGL 3.2 Core profile with 24-bit color and depth + int[] attribs = [ + 99, // NSOpenGLPFAOpenGLProfile + 0x3200, // NSOpenGLProfileVersion3_2Core + 5, // NSOpenGLPFADoubleBuffer + 8, 24, // NSOpenGLPFAColorSize + 12, 24, // NSOpenGLPFADepthSize + 73, // NSOpenGLPFAAccelerated + 0 // terminator + ]; + + // Create the pixel format object + nint fmtAlloc = MacOpenGLImports.objc_msgSend(fmtClass, allocSel); + nint pixelFormat; + fixed (int* attribPtr = attribs) { + pixelFormat = MacOpenGLImports.objc_msgSend(fmtAlloc, initAttrSel, (nint) attribPtr); + } + if (pixelFormat == 0) { + throw new InvalidOperationException("Failed to create NSOpenGLPixelFormat."); + } + + // Create an OpenGL context + nint ctxAlloc = MacOpenGLImports.objc_msgSend(ctxClass, allocSel); + nint context = MacOpenGLImports.objc_msgSend(ctxAlloc, initFormatSel, pixelFormat, 0); + if (context == 0) { + throw new InvalidOperationException("Failed to create NSOpenGLContext."); + } + + // Make the context current + MacOpenGLImports.objc_msgSend(context, makeCurrentSel); + + // Return the created context + return context; + } + + /// + public bool TryDeleteContext(nint context, IRenderTarget target) { + // Context deletion is handled by the Objective-C runtime (neat!) + return true; + } + + /// + public bool TryMakeContextCurrent(nint context, IRenderTarget target) { + nint makeCurrentSel = MacOpenGLImports.sel_registerName("makeCurrentContext"); + MacOpenGLImports.objc_msgSend(context, makeCurrentSel); + return true; + } + + /// + public bool SwapBuffers(IRenderTarget target) { + nint currentSel = MacOpenGLImports.sel_registerName("currentContext"); + nint context = MacOpenGLImports.objc_msgSend(MacOpenGLImports.objc_getClass("NSOpenGLContext"), currentSel); + nint flushSel = MacOpenGLImports.sel_registerName("flushBuffer"); + MacOpenGLImports.objc_msgSend(context, flushSel); + return true; + } + + /// + public bool SetSwapInterval(int interval) { + nint currentSel = MacOpenGLImports.sel_registerName("currentContext"); + nint context = MacOpenGLImports.objc_msgSend(MacOpenGLImports.objc_getClass("NSOpenGLContext"), currentSel); + if (context == 0) return false; + nint setSwapSel = MacOpenGLImports.sel_registerName("setValues:forParameter:"); + int[] args = [ interval ]; + fixed (int* argPtr = args) { + MacOpenGLImports.objc_msgSend(context, setSwapSel, (nint)argPtr, 222); // 222 = NSOpenGLCPSwapInterval + } + return true; + } + + } + + // ReSharper disable InconsistentNaming + internal static unsafe class MacOpenGLImports { + + // ObjC imports + [DllImport("/usr/lib/libobjc.A.dylib")] + internal static extern nint objc_getClass(string name); + + [DllImport("/usr/lib/libobjc.A.dylib")] + internal static extern nint sel_registerName(string name); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + internal static extern nint objc_msgSend(nint receiver, nint selector); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + internal static extern nint objc_msgSend(nint receiver, nint selector, nint arg1); + + [DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")] + internal static extern nint objc_msgSend(nint receiver, nint selector, nint arg1, nint arg2); + + internal const int RTLD_LAZY = 0x1; + internal static readonly nint _glHandle = dlopen("/System/Library/Frameworks/OpenGL.framework/OpenGL", RTLD_LAZY); + + // From libSystem + [DllImport("libSystem.B.dylib", EntryPoint = "dlsym")] + internal static extern nint dlsym(nint handle, string symbol); + + [DllImport("libSystem.B.dylib", EntryPoint = "dlopen")] + internal static extern nint dlopen(string path, int mode); + + // Externally defined RTLD_DEFAULT (symbol reference) + [DllImport("libSystem.B.dylib", EntryPoint = "dlsym")] + internal static extern nint dlsym_default(nint handle, [MarshalAs(UnmanagedType.LPStr)] string symbol); + + internal static readonly nint RTLD_DEFAULT = (nint)(-2); + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGL.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGL.cs new file mode 100644 index 0000000..ad0ef58 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGL.cs @@ -0,0 +1,207 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Attributes.Threading; +using Catalyst.Debugging; +using Catalyst.Domains; +using Catalyst.Layers; +using Catalyst.Native; +using Catalyst.Threading; +using Silk.NET.OpenGL; +using System; +using System.Threading; + +namespace Catalyst.Modules.Crystal.OpenGL { + + /// + /// Native API wrapper for the OpenGL library. + /// + public sealed partial class OpenGL : INativeApi { + + /// + /// The underlying wrapped API instance. + /// + private static GL? _api; + + /// + /// The dispatcher which was used to initialize the API. + /// + private static ThreadDelegateDispatcher? _initDispatcher; + + /// + /// The total number of 'requests' or instantiations of the API. + /// + private static ushort _referenceCount; + + /// + /// A static lock used to ensure thread-safe access to the static members. + /// + private static readonly Lock _staticLock; + + /// + /// Gets the OpenGL debug context. + /// + /// The debug context for OpenGL. + public static DebugContext DebugContext { get; } + + /// + /// Gets the wrapped API instance. + /// + /// The wrapped OpenGL API instance. + public GL Api { + get { + _staticLock.Enter(); + try { + return _api!; // Non-nullable because it is initialized in the static constructor + } finally { + _staticLock.Exit(); + } + } + } + + /// + /// A flag indicating whether the object has been disposed of. + /// + private bool _disposed; + + /// + /// A lock used to ensure thread-safe access to the object. + /// + private readonly Lock _lock; + + /// + /// Static constructor for . + /// + static OpenGL() { + // Fields + _referenceCount = 0; + _staticLock = new(); + + // Properties + DebugContext = CatalystDebug.ForContext("OpenGL"); + } + + /// + /// Constructs a new . + /// + public OpenGL() { + // Fields + _disposed = false; + _lock = new(); + } + + /// + /// Disposes of the . + /// + ~OpenGL() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: false); + } + + /// + public static OpenGL GetInstance(ThreadDelegateDispatcher? dispatcher = null) { + _staticLock.Enter(); + try { + if (_referenceCount == 0) Initialize(dispatcher); + _referenceCount++; + return new(); + } finally { + _staticLock.Exit(); + } + } + + /// + /// Initializes the OpenGL API. + /// + /// A thread dispatcher to associate with the API instance, or to use a captured main thread dispatcher. + private static void Initialize(ThreadDelegateDispatcher? dispatcher = null) { + if (dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(OpenGL), nameof(GetInstance)); + dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } + if (!dispatcher.Execute(_cachedActionInitializeUnsafe, wait: true)) { + throw new TypeInitializationException(nameof(OpenGL), new InvalidOperationException("Failed to initialize OpenGL API.")); + } + _initDispatcher = dispatcher; + } + + [CachedDelegate] + private static void InitializeUnsafe() { + if (_api != null) throw new InvalidOperationException("The OpenGL API is already initialized."); + IOpenGLNativeConnector> nativeConnector; + try { + nativeConnector = ModelRegistry.RequestConnector>>(); + } catch { + throw new PlatformNotSupportedException("No suitable OpenGL native connector is available for the current platform."); + } + _api = GL.GetApi(nativeConnector.GetProcAddress); + } + + /// + /// Terminates the OpenGL API. + /// + /// The thread dispatcher associated with the API instance, or to use the captured main thread dispatcher. + private static void Terminate(ThreadDelegateDispatcher? dispatcher = null) { + if (dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(OpenGL), nameof(Terminate)); + dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } + if (!dispatcher.Execute(_cachedActionTerminateUnsafe, wait: true)) { + throw new InvalidOperationException("Failed to terminate OpenGL API."); + } + } + + [CachedDelegate] + private static void TerminateUnsafe() { + _api?.Dispose(); + _api = null; + } + + /// + /// Disposes of the . + /// + public void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// if disposal is being performed by the garbage collector, otherwise + /// + private void Dispose(bool disposing) { + _lock.Enter(); + try { + if (_disposed) return; + + // Dispose managed state (managed objects) + if (disposing) { + // ... + } + + // Dispose unmanaged state (unmanaged objects) + _staticLock.Enter(); + try { + _referenceCount--; + if (_referenceCount == 0) Terminate(_initDispatcher); + } finally { + _staticLock.Exit(); + } + + // Indicate disposal completion + _disposed = true; + } finally { + _lock.Exit(); + } + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs new file mode 100644 index 0000000..1b09df3 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +namespace Catalyst.Modules.Crystal.OpenGL { + + /// + /// The Crystal implementation of the CatalystUI type + /// using the OpenGL rendering API. + /// + public sealed class OpenGLRendererLayer : IRendererLayer { + + // ... + + } + +} \ No newline at end of file From c19ffea3d23a739cb68a7dcb5ef2f23932a0a686 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Mon, 10 Nov 2025 19:28:30 -0700 Subject: [PATCH 06/14] Continue work on the OpenGL project * Added a basic impl for IRenderer for OpenGLRenderer * Minor changes to IRenderer (adding the dispatcher, depth clarification for Clear, and nullability on SetTarget) * Added RendererOptions * Added RenderLayerException * Added CreateRenderer to IRendererLayer * Fixed naming in the native connector --- .../IRendererLayer.cs | 12 +- .../Renderer/IRenderer.cs | 14 +- .../Renderer/RenderLayerException.cs | 49 ++ .../Renderer/RendererOptions.cs | 86 +++ .../CatalystUI.Modules.Crystal.OpenGL.csproj | 3 - .../IOpenGLNativeConnector.cs | 4 +- .../OpenGLMacNativeConnector.cs | 4 +- .../OpenGLRendererLayer.cs | 7 +- .../Renderer/OpenGLRenderer.cs | 511 ++++++++++++++++++ 9 files changed, 678 insertions(+), 12 deletions(-) create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RenderLayerException.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/IRendererLayer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/IRendererLayer.cs index 5c37d90..8df8524 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/IRendererLayer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/IRendererLayer.cs @@ -19,7 +19,17 @@ namespace Catalyst.Modules.Crystal { /// public interface IRendererLayer : IRendererLayer { - // ... + /// + /// Constructs a new renderer with the specified options. + /// + /// + /// It is generally recommended to provide a custom + /// in the instead of using the main thread to avoid + /// blocking windowing operations. + /// + /// The options to use when creating the renderer, or to use defaults. + /// The created renderer. + IRenderer CreateRenderer(RendererOptions? options = null); } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderer.cs index 5ad0e6b..7640d4f 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderer.cs @@ -10,6 +10,7 @@ // ------------------------------------------------------------------------------------------------- using Catalyst.Mathematics.Color; +using Catalyst.Threading; using System; using System.Collections.Generic; @@ -55,6 +56,12 @@ public interface IRenderer : IDisposable { /// event RendererEventHandler? FrameEnd; + /// + /// Gets the threading dispatcher associated with the renderer. + /// + /// The renderer's thread dispatcher. + ThreadDelegateDispatcher Dispatcher { get; } + /// /// Gets or sets the render target for the renderer. /// @@ -137,8 +144,8 @@ public interface IRenderer : IDisposable { /// /// Sets the render target for the renderer. /// - /// The render target to set. - void SetTarget(IRenderTarget target); + /// The render target to set, or to clear the target. + void SetTarget(IRenderTarget? target); /// /// Attempts to register a new render layer with the renderer. @@ -163,7 +170,8 @@ public interface IRenderer : IDisposable { /// Clears the rendering surface with the specified color. /// /// The color to clear the surface with. - void Clear(Color128 color); + /// to also clear the depth buffer if available; otherwise, . + void Clear(Color128 color, bool depth = false); } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RenderLayerException.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RenderLayerException.cs new file mode 100644 index 0000000..33c7502 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RenderLayerException.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using System; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Modules.Crystal { + + /// + /// Represents an error that occurs during a render layer's rendering operations. + /// + /// + public class RenderLayerException : Exception { + + /// + /// Initializes a new instance of the class. + /// + public RenderLayerException() : base() { + // ... + } + + /// + /// Initializes a new instance of the class + /// with a specified error message. + /// + public RenderLayerException(string message) : base(message) { + // ... + } + + /// + /// Initializes a new instance of the class + /// with a specified error message and a reference to the inner exception + /// that is the cause of this exception. + /// + public RenderLayerException(string message, Exception innerException) : base(message, innerException) { + // ... + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs new file mode 100644 index 0000000..d595b2a --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Threading; + +// ReSharper disable once CheckNamespace +namespace Catalyst.Modules.Crystal { + + /// + /// Represents options for constructing/configuring a renderer. + /// + public readonly record struct RendererOptions : IOptions { + + /// The renderer's thread dispatcher, or to use a captured . + /// + public ThreadDelegateDispatcher? Dispatcher { get; init; } = null; + + /// + public IRenderTarget? Target { get; init; } = null; + + /// + public IRenderLayer[] Layers { get; init; } = []; + + /// + public bool UseVSync { get; init; } = true; + + /// + public ushort TargetFrameRate { get; init; } = 0; + + /// + public bool ShouldMeasureFrameRate { get; init; } = false; + + /// + /// Invoked after the renderer is initialized and + /// prepared to be created, but prior to the + /// first frame being rendered. + /// + public IRenderer.RendererEventHandler? InitializedHandler { get; init; } = null; + + /// + /// Constructs a new . + /// + /// The renderer's thread dispatcher. + /// The renderer's target. + /// The renderer's layers. + /// to use VSync; otherwise, . + /// The renderer's target frame rate. + /// to measure the frame rate; otherwise, . + /// An optional handler invoked when the renderer is initialized. + public RendererOptions( + ThreadDelegateDispatcher? dispatcher = null, + IRenderTarget? target = null, + IRenderLayer[]? layers = null, + bool useVSync = true, + ushort targetFrameRate = 0, + bool shouldMeasureFrameRate = false, + IRenderer.RendererEventHandler? initializedHandler = null + ) { + Dispatcher = dispatcher; + Target = target; + Layers = layers ?? []; + UseVSync = useVSync; + TargetFrameRate = targetFrameRate; + ShouldMeasureFrameRate = shouldMeasureFrameRate; + InitializedHandler = initializedHandler; + } + + /// + /// Constructs a new + /// with default values. + /// + public RendererOptions() { + // ... + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj index ed069c0..fac9425 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/CatalystUI.Modules.Crystal.OpenGL.csproj @@ -21,8 +21,5 @@ - - - \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs index 7012d20..7514ce3 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/IOpenGLNativeConnector.cs @@ -58,14 +58,14 @@ public interface IOpenGLNativeConnector : INativeConnector /// The render target. /// if the buffers were successfully swapped; otherwise, . - bool SwapBuffers(IRenderTarget target); + bool TrySwapBuffers(IRenderTarget target); /// /// Sets the swap interval for the current OpenGL context. /// /// 0 to disable V-Sync, 1 to enable V-Sync. /// if the swap interval was successfully set; otherwise, . - bool SetSwapInterval(int interval); + bool TrySetSwapInterval(int interval); } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs index efd15b3..ae5b830 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs @@ -87,7 +87,7 @@ public bool TryMakeContextCurrent(nint context, IRenderTarget target) { } /// - public bool SwapBuffers(IRenderTarget target) { + public bool TrySwapBuffers(IRenderTarget target) { nint currentSel = MacOpenGLImports.sel_registerName("currentContext"); nint context = MacOpenGLImports.objc_msgSend(MacOpenGLImports.objc_getClass("NSOpenGLContext"), currentSel); nint flushSel = MacOpenGLImports.sel_registerName("flushBuffer"); @@ -96,7 +96,7 @@ public bool SwapBuffers(IRenderTarget target) { } /// - public bool SetSwapInterval(int interval) { + public bool TrySetSwapInterval(int interval) { nint currentSel = MacOpenGLImports.sel_registerName("currentContext"); nint context = MacOpenGLImports.objc_msgSend(MacOpenGLImports.objc_getClass("NSOpenGLContext"), currentSel); if (context == 0) return false; diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs index 1b09df3..e0d9bff 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/OpenGLRendererLayer.cs @@ -9,6 +9,8 @@ // For full terms, see the LICENSE and NOTICE files in the project root. // ------------------------------------------------------------------------------------------------- +using Catalyst.Modules.Crystal.OpenGL.Renderer; + namespace Catalyst.Modules.Crystal.OpenGL { /// @@ -17,7 +19,10 @@ namespace Catalyst.Modules.Crystal.OpenGL { /// public sealed class OpenGLRendererLayer : IRendererLayer { - // ... + /// + public IRenderer CreateRenderer(RendererOptions? options = null) { + return new OpenGLRenderer(options ?? new()); + } } diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs new file mode 100644 index 0000000..5fe2ad6 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs @@ -0,0 +1,511 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Debugging; +using Catalyst.Domains; +using Catalyst.Layers; +using Catalyst.Mathematics.Color; +using Catalyst.Threading; +using Silk.NET.OpenGL; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +namespace Catalyst.Modules.Crystal.OpenGL.Renderer { + + /// + /// An implementation of using + /// the OpenGL rendering API. + /// + public class OpenGLRenderer : IRenderer { + + /// + public event IRenderer.RendererErroredEventHandler? Errored; + + /// + public event IRenderer.RendererEventHandler? Created; + + /// + public event IRenderer.RendererEventHandler? FrameStart; + + /// + public event IRenderer.RendererEventHandler? FrameEnd; + + /// + /// Gets a generated log identifier for the renderer. + /// + /// The renderer class name and context handle in hexadecimal format. + protected string LogId => $"{nameof(OpenGLRenderer)} {(OpenGLContext != 0 ? $"0x{OpenGLContext:X}" : "(No Context)")}"; + + /// + /// Gets or sets the OpenGL instance associated with the renderer. + /// + /// The OpenGL instance. + public OpenGL OpenGL { get; private set; } + + /// + /// Gets or sets the OpenGL context handle associated with the renderer. + /// + /// The OpenGL context handle. + public nint OpenGLContext { get; private set; } + + /// + /// Gets the OpenGL native connector for the renderer. + /// + /// The OpenGL native connector. + public IOpenGLNativeConnector> OpenGLNative { get; private set; } + + /// + public ThreadDelegateDispatcher Dispatcher { get; init; } + + /// + /// Internal reference for . + /// + protected volatile IRenderTarget? _target; + + /// + public virtual IRenderTarget? Target { + get => _target; + set => SetTarget(value); + } + + /// + /// Internal reference for . + /// + protected volatile List _layers; + + /// + public virtual IReadOnlyList Layers => _layers; + + /// + /// Internal reference for . + /// + protected volatile bool _useVSync; + + /// + public virtual bool UseVSync { + get => _useVSync; + set { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + _useVSync = value; + if (OpenGLContext == nint.Zero) return; + if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target!)) { + OpenGL.DebugContext.Log(LogLevel.Verbose, "Failed to make OpenGL context current; cannot update VSync setting.", prefix: LogId); + return; + } + if (_useVSync) { + OpenGL.DebugContext.Log(LogLevel.Verbose, "Enabling VSync...", prefix: LogId); + if (!OpenGLNative.TrySetSwapInterval(1)) { + OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to enable VSync.", prefix: LogId); + } else { + OpenGL.DebugContext.Log(LogLevel.Verbose, "VSync enabled.", prefix: LogId); + } + } else { + OpenGL.DebugContext.Log(LogLevel.Verbose, "Disabling VSync...", prefix: LogId); + if (!OpenGLNative.TrySetSwapInterval(0)) { + OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to disable VSync.", prefix: LogId); + } else { + OpenGL.DebugContext.Log(LogLevel.Verbose, "VSync disabled.", prefix: LogId); + } + } + }, wait: true); + } + } + + /// + /// Internal reference for . + /// + protected volatile ushort _targetFrameRate; + + /// + public virtual ushort TargetFrameRate { + get => _targetFrameRate; + set => throw new NotImplementedException(); + } + + /// + /// Internal reference for . + /// + protected volatile bool _shouldMeasureFrameRate; + + /// + public virtual bool ShouldMeasureFrameRate { + get => _shouldMeasureFrameRate; + set => throw new NotImplementedException(); + } + + /// + public virtual double AvgFrameRate { get; protected set; } + + /// + public virtual double AvgDeltaTime { get; protected set; } + + /// + /// Used to ensure viewport is only updated when necessary. + /// + protected uint _previousTargetWidth; + + /// + /// Used to ensure viewport is only updated when necessary. + /// + protected uint _previousTargetHeight; + + // Cached delegates for callbacks for performance +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + protected readonly ThreadDelegateDispatcher.DispatcherEventHandler _handlePreExecute; + protected readonly ThreadDelegateDispatcher.DispatcherQueueEventHandler _handleDelegateEnqueued; +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// A flag indicating whether the object has been disposed of. + /// + private bool _disposed; + + /// + /// A lock used to ensure thread-safe access to the object. + /// + protected readonly ReaderWriterLockSlim _lock; + + /// + /// Constructs a new . + /// + public OpenGLRenderer(RendererOptions options) { + // Fields + _target = null; // handled later + _layers = []; // handled later + _useVSync = options.UseVSync; + _targetFrameRate = options.TargetFrameRate; + _shouldMeasureFrameRate = options.ShouldMeasureFrameRate; + _previousTargetWidth = 0; + _previousTargetHeight = 0; + _handlePreExecute = HandlePreExecute; + _handleDelegateEnqueued = HandleDelegateEnqueued; + _disposed = false; + _lock = new(LockRecursionPolicy.SupportsRecursion); + + // Properties + OpenGL = null!; + OpenGLContext = nint.Zero; + OpenGLNative = null!; + if (options.Dispatcher == null) { + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) { + OpenGL.DebugContext.Log(LogLevel.Error, $"A main-thread dispatcher is required to create a {nameof(OpenGLRenderer)} when no dispatcher is provided.", prefix: LogId); + throw new RequiresMainThreadException(nameof(OpenGLRenderer), "constructor"); + } + Dispatcher = ThreadDelegateDispatcher.MainThreadDispatcher; + } else { + Dispatcher = options.Dispatcher; + } + + // Wait for other processes if they are initializing + using Mutex mutex = new(true, "Global\\CatalystUI_OpenGL_Lock", out bool newMutex); + if (!newMutex) { + if (!mutex.WaitOne(ThreadDelegateDispatcher.LockoutTimeout)) { + throw new RendererException("Failed to acquire mutex lock for OpenGL renderer initialization."); + } + } + try { + // Perform window initialization + if (!Dispatcher.Execute(() => { + // Request an OpenGL API instance and associated native connector + OpenGL.DebugContext.Log(LogLevel.Verbose, "Requesting OpenGL instance..."); + OpenGL = OpenGL.GetInstance(Dispatcher); + OpenGL.DebugContext.Log(LogLevel.Verbose, "Fetching OpenGL native connector..."); + // the following line throws if it doesn't exist. we perform checks elsewhere, so if this + // fails, it means the end-user constructed the renderer directly and should handle the + // error themselves. + OpenGLNative = ModelRegistry.RequestConnector>>(); + OpenGL.DebugContext.Log(LogLevel.Verbose, "Done."); + + // Check for a render target + if (options.Target != null) { + // Set the render target + OpenGL.DebugContext.Log(LogLevel.Verbose, $"Setting render target {options.Target.GetType().Name}..."); + SetTarget(options.Target); + + // Set the layers + for (int i = 0; i < options.Layers.Length; i++) { + IRenderLayer layer = options.Layers[i]; + OpenGL.DebugContext.Log(LogLevel.Verbose, $"Registering layer {layer.GetType().Name}..."); + TryRegisterLayer(layer); + } + } else { + OpenGL.DebugContext.Log(LogLevel.Info, "The OpenGL context cannot be set until a render target is provided. The renderer will be inactive until a target is set.", prefix: LogId); + } + + // Fire the initialized handler + options.InitializedHandler?.Invoke(this); + + // Fire initial render call + Render(); + + // Fire created event + OnCreated(); + + // Attach renderer event loop + OpenGL.DebugContext.Log(LogLevel.Verbose, "Starting render loop...", prefix: LogId); + Dispatcher.PreExecute += _handlePreExecute; + Dispatcher.DelegateEnqueued += _handleDelegateEnqueued; + }, wait: true, timeout: Timeout.Infinite)) { + throw new RendererException("Failed to initialize the renderer."); + } + } finally { + // Release the mutex + mutex.ReleaseMutex(); + } + } + + /// + /// Disposes of the . + /// + ~OpenGLRenderer() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: false); + } + + /// + public void SetTarget(IRenderTarget? target) { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_target == target) return; + _ = Dispatcher.Execute(() => { + // Delete the existing context if one exists + if (_target != null) { + OpenGL.DebugContext.Log(LogLevel.Debug, $"Deleting existing OpenGL context for target {_target.GetType().Name}...", prefix: LogId); + if (!OpenGLNative.TryDeleteContext(OpenGLContext, _target)) { + OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to delete existing OpenGL context.", prefix: LogId); + } else { + OpenGL.DebugContext.Log(LogLevel.Debug, "Existing OpenGL context deleted.", prefix: LogId); + } + OpenGLContext = nint.Zero; + } + + // If we don't have a new target to set, we're all done + if (target == null) { + OpenGL.DebugContext.Log(LogLevel.Debug, "No new render target provided; renderer is now inactive.", prefix: LogId); + _target = null; + return; + } + + // Register the new target + _target = target; + + // Create and bind the OpenGL context + OpenGL.DebugContext.Log(LogLevel.Debug, $"Creating OpenGL context for target {target.GetType().Name}...", prefix: LogId); + OpenGLContext = OpenGLNative.CreateContext(OpenGL, _target); + if (OpenGLContext == 0) throw new RendererException("Failed to create OpenGL context."); + if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target)) throw new RendererException("Failed to make OpenGL context current."); + OpenGL.DebugContext.Log(LogLevel.Debug, "OpenGL context created and made current.", prefix: LogId); + + // Set the swap interval + UseVSync = _useVSync; + +#if DEBUG + // Mark OpenGL debug info + unsafe { + GL gl = OpenGL.Api; + if (gl.IsExtensionPresent("GL_ARB_debug_output")) { + gl.Enable(EnableCap.DebugOutput); + gl.Enable(EnableCap.DebugOutputSynchronous); + // TODO: small memory leak? + gl.DebugMessageCallback((source, type, id, severity, length, message, userParam) => { + if (severity == (GLEnum) DebugSeverity.DebugSeverityNotification) return; // Ignore notifications + if (severity == (GLEnum) DebugSeverity.DebugSeverityLow) return; // Ignore low-severity hints + ReadOnlySpan span = new((void*) message, length); + OnErrored(new(Encoding.UTF8.GetString(span))); + }, (void*) 0); + gl.DebugMessageControl(DebugSource.DontCare, DebugType.DontCare, DebugSeverity.DontCare, 0, null, true); + } + + } +#endif + }, wait: true); + } + + /// + public bool TryRegisterLayer(IRenderLayer layer) { + ObjectDisposedException.ThrowIf(_disposed, this); + bool result = false; + _ = Dispatcher.Execute(() => { + if (_layers.Contains(layer)) { + OpenGL.DebugContext.Log(LogLevel.Warning, $"Layer {layer.GetType().Name} is already registered; skipping...", prefix: LogId); + result = false; + return; + } + _layers.Add(layer); + OpenGL.DebugContext.Log(LogLevel.Debug, $"Layer {layer.GetType().Name} registered.", prefix: LogId); + result = true; + }, wait: true); + return result; + } + + /// + public void UnregisterLayer(IRenderLayer layer) { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + if (!_layers.Contains(layer)) { + OpenGL.DebugContext.Log(LogLevel.Warning, $"Layer {layer.GetType().Name} is not registered; cannot unregister.", prefix: LogId); + return; + } + _layers.Remove(layer); + OpenGL.DebugContext.Log(LogLevel.Debug, $"Layer {layer.GetType().Name} unregistered.", prefix: LogId); + }, wait: true); + } + + /// + public void Render() { + if (_disposed) return; + if (_target is not { IsEnabled: true }) return; + if (_target.Width == 0 || _target.Height == 0) return; + if (OpenGLContext == nint.Zero) return; + if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target)) { + OpenGL.DebugContext.Log(LogLevel.Verbose, "Failed to make OpenGL context current; skipping render.", prefix: LogId); + return; + } + GL gl = OpenGL.Api; + + // Frame start + OnFrameStart(); + + // Update the viewport + uint targetWidth = _target.Width; + uint targetHeight = _target.Height; + if (targetWidth != _previousTargetWidth || targetHeight != _previousTargetHeight) { + OpenGL.DebugContext.Log(LogLevel.Verbose, $"Updating viewport to {targetWidth}x{targetHeight}...", prefix: LogId); + gl.Viewport(0, 0, targetWidth, targetHeight); + _previousTargetWidth = targetWidth; + _previousTargetHeight = targetHeight; + } + + // If there's no layers, clear to white and present + // Otherwise, loop through the layers and pass control + if (_layers.Count == 0) { + gl.ClearColor(Color128.WHITE.R, Color128.WHITE.G, Color128.WHITE.B, Color128.WHITE.A); + gl.Clear((uint) ClearBufferMask.ColorBufferBit); + } else { + for (int i = 0; i < _layers.Count; i++) { + try { + _layers[i].Render(this); + } catch (RenderLayerException e) { + OnErrored(new($"Exception occurred while rendering layer {_layers[i].GetType().Name}: {e.Message}{Environment.NewLine}{e.StackTrace}")); + } catch (Exception e) { + OnErrored(new($"Non-layer exception occurred while rendering layer {_layers[i].GetType().Name}: {e.Message}{Environment.NewLine}{e.StackTrace}")); + } + } + } + + // Swap the buffers + if (!OpenGLNative.TrySwapBuffers(_target)) { + OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to swap buffers after rendering.", prefix: LogId); + } + + // Frame end + OnFrameEnd(); + } + + /// + public void Clear(Color128 color, bool depth) { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + if (_disposed) return; + if (_target is not { IsEnabled: true }) return; + if (_target.Width == 0 || _target.Height == 0) return; + if (OpenGLContext == nint.Zero) return; + if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target)) { + OpenGL.DebugContext.Log(LogLevel.Verbose, "Failed to make OpenGL context current; skipping render.", prefix: LogId); + return; + } + GL gl = OpenGL.Api; + + // Frame start + OnFrameStart(); + + // Clear the buffers + gl.Viewport(0, 0, _target.Width, _target.Height); + gl.ClearColor(color.R, color.G, color.B, color.A); + gl.Clear((uint) (ClearBufferMask.ColorBufferBit | (depth ? ClearBufferMask.DepthBufferBit : 0))); + gl.Flush(); + + // Swap the buffers + if (!OpenGLNative.TrySwapBuffers(_target)) { + OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to swap buffers after clearing.", prefix: LogId); + } + + // Frame end + OnFrameEnd(); + }, wait: true); + } + + /// + protected virtual void HandlePreExecute(ThreadDelegateDispatcher dispatcher) { + // ... + } + + /// + protected virtual void HandleDelegateEnqueued(ThreadDelegateDispatcher dispatcher, Delegate @delegate) { + // ... + } + + /// + protected virtual void OnErrored(RendererException exception) { + Errored?.Invoke(this, exception); + } + + /// + protected virtual void OnCreated() { + Created?.Invoke(this); + } + + /// + protected virtual void OnFrameStart() { + FrameStart?.Invoke(this); + } + + /// + protected virtual void OnFrameEnd() { + FrameEnd?.Invoke(this); + } + + /// + /// Disposes of the . + /// + public virtual void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// if disposal is being performed by the garbage collector, otherwise + /// + private void Dispose(bool disposing) { + _lock.EnterWriteLock(); + try { + if (_disposed) return; + + // Dispose managed state (managed objects) + if (disposing) { + // ... + } + + // Dispose unmanaged state (unmanaged objects) + // ... + + // Indicate disposal completion + _disposed = true; + } finally { + _lock.ExitWriteLock(); + } + } + + } + +} \ No newline at end of file From 9bcfe338bdff2118c785185cd435e7cb88bb3302 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Mon, 10 Nov 2025 20:56:02 -0700 Subject: [PATCH 07/14] OpenGL on MacOS is a reality :sunglasses: --- .scripts/Setup.ps1 | 16 +- CatalystUI/CatalystUI.sln | 7 + CatalystUI/CatalystUI.sln.DotSettings | 2 + .../ISurfaceConnector.cs | 30 +++ .../Renderer/IRenderLayer.cs | 2 + .../Renderer/IRenderTarget.cs | 22 +- .../Window/IWindow.cs | 9 + .../Window/GlfwWindow.cs | 14 +- ...Crystal.Glfw3OpenGLSurfaceConnector.csproj | 25 +++ .../CatalystAppBuilderExtensions.cs | 45 ++++ .../Glfw3OpenGLRenderTarget.cs | 151 ++++++++++++++ .../Glfw3OpenGLSurfaceConnector.cs | 31 +++ .../OpenGLMacNativeConnector.cs | 80 +++++--- .../Renderer/OpenGLRenderer.cs | 194 +++++++++++++++--- .../Window/SdlWindow.cs | 12 ++ 15 files changed, 571 insertions(+), 69 deletions(-) create mode 100644 CatalystUI/CatalystUI.sln.DotSettings create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/ISurfaceConnector.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector.csproj create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Extensions/CatalystAppBuilderExtensions.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLRenderTarget.cs create mode 100644 CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLSurfaceConnector.cs diff --git a/.scripts/Setup.ps1 b/.scripts/Setup.ps1 index 3eecb92..992dc38 100644 --- a/.scripts/Setup.ps1 +++ b/.scripts/Setup.ps1 @@ -73,7 +73,7 @@ $projectsList = @( Depends = @("Arcane") }, @{ - Module = "Crystal" + Module = "Crystal.Core" Projects = @( @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.Core" } ) @@ -86,7 +86,7 @@ $projectsList = @( @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.Glfw3" } ) PromptIgnore = $false - Depends = @("Crystal") + Depends = @("Crystal.Core") }, @{ Module = "Crystal.Sdl2" @@ -94,7 +94,7 @@ $projectsList = @( @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.Sdl2" } ) PromptIgnore = $false - Depends = @("Crystal") + Depends = @("Crystal.Core") }, @{ Module = "Crystal.OpenGL" @@ -102,8 +102,16 @@ $projectsList = @( @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.OpenGL" } ) PromptIgnore = $false - Depends = @("Crystal") + Depends = @("Crystal.Core") }, + @{ + Module = "Crystal.Glfw3OpenGLSurfaceConnector" + Projects = @( + @{ Folder = "Modules/Crystal"; Name = "CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector" } + ) + PromptIgnore = $false + Depends = @("Crystal.Glfw3", "Crystal.OpenGL") + } ) # Build prompt options diff --git a/CatalystUI/CatalystUI.sln b/CatalystUI/CatalystUI.sln index a371292..ce4f002 100644 --- a/CatalystUI/CatalystUI.sln +++ b/CatalystUI/CatalystUI.sln @@ -44,6 +44,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.OpenGL", "Modules\Crystal\CatalystUI.Modules.Crystal.OpenGL\CatalystUI.Modules.Crystal.OpenGL.csproj", "{9CD307E2-FEDA-4C64-B2EE-00E6E196175C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector", "Modules\Crystal\CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector\CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector.csproj", "{B03716DE-8B0C-4457-B71A-5E0A0D11325D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -114,6 +116,10 @@ Global {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Debug|Any CPU.Build.0 = Debug|Any CPU {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Release|Any CPU.ActiveCfg = Release|Any CPU {9CD307E2-FEDA-4C64-B2EE-00E6E196175C}.Release|Any CPU.Build.0 = Release|Any CPU + {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {68F496AC-9438-40F1-9DF8-97363033D661} = {7EC51871-49A8-4991-BDAF-F43A4E9B8C9D} @@ -135,5 +141,6 @@ Global {BC5802D1-4C3E-4FD6-9E3B-9ED82CCB2D18} = {41BEF490-7005-4D10-9958-22D636F9DE38} {532C59A0-43D2-44E9-B6ED-E88E6B8770F5} = {41BEF490-7005-4D10-9958-22D636F9DE38} {9CD307E2-FEDA-4C64-B2EE-00E6E196175C} = {41BEF490-7005-4D10-9958-22D636F9DE38} + {B03716DE-8B0C-4457-B71A-5E0A0D11325D} = {41BEF490-7005-4D10-9958-22D636F9DE38} EndGlobalSection EndGlobal diff --git a/CatalystUI/CatalystUI.sln.DotSettings b/CatalystUI/CatalystUI.sln.DotSettings new file mode 100644 index 0000000..d56d2c4 --- /dev/null +++ b/CatalystUI/CatalystUI.sln.DotSettings @@ -0,0 +1,2 @@ + + GL \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/ISurfaceConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/ISurfaceConnector.cs new file mode 100644 index 0000000..d8254b4 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/ISurfaceConnector.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Connectors; + +namespace Catalyst.Modules.Crystal { + + /// + /// Represents the surface connector for visual or graphical windowing to rendering. + /// + public interface ISurfaceConnector : ISurfaceConnector { + + /// + /// Creates a render target for the specified window. + /// + /// The window to create the render target for. + /// The created render target. + IRenderTarget CreateRenderTarget(IWindow window); + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderLayer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderLayer.cs index e0edf99..01e5885 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderLayer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderLayer.cs @@ -9,6 +9,8 @@ // For full terms, see the LICENSE and NOTICE files in the project root. // ------------------------------------------------------------------------------------------------- + + // ReSharper disable once CheckNamespace namespace Catalyst.Modules.Crystal { diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderTarget.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderTarget.cs index 6cd6f0d..b0e64d9 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderTarget.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/IRenderTarget.cs @@ -9,6 +9,8 @@ // For full terms, see the LICENSE and NOTICE files in the project root. // ------------------------------------------------------------------------------------------------- + + // ReSharper disable once CheckNamespace namespace Catalyst.Modules.Crystal { @@ -115,16 +117,24 @@ public interface IRenderTarget { double SurfaceHeight { get; } /// - /// Gets the pixel density of the surface. + /// Gets the PPI (pixels per inch) of the surface. + /// + /// The PPI of the surface. + /// + double SurfacePpi { get; } + + /// + /// Gets the scaling factor of the surface. /// - /// The pixel density of the surface. - double SurfacePixelDensity { get; } + /// The scaling factor of the surface. + /// + double SurfaceScalingFactor { get; } /// - /// Gets the DPI (dots per inch) of the surface. + /// Gets a value indicating whether the render target is currently visible. /// - /// The DPI of the surface. - double SurfaceDpi { get; } + /// if the render target is visible; otherwise, . + bool IsVisible { get; } /// /// Gets a value indicating whether the render target is currently enabled. diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/IWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/IWindow.cs index 28f0cc6..37cdc69 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/IWindow.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/IWindow.cs @@ -159,6 +159,15 @@ public interface IWindow : IDisposable { /// event WindowClosingEventHandler? Closing; + /// + /// Invoked when the window is disposing of its resources. + /// + /// + /// Raised after the window close operation has been + /// confirmed, but before resources have been released. + /// + event WindowEventHandler? Disposing; + /// /// Invoked when the window has closed and resources have been released. /// diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs index 7a5b945..bdbcecb 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3/Window/GlfwWindow.cs @@ -35,7 +35,7 @@ public unsafe class GlfwWindow : IWindow { /// The minimum polling rate for the window in milliseconds. /// /// - /// Prevents lockups of the main thread when the + /// Prevents lockups of the thread when the /// window may not be responding. /// public const ushort MINIMUM_POLL_RATE = 3000; @@ -82,6 +82,9 @@ public unsafe class GlfwWindow : IWindow { /// public event IWindow.WindowClosingEventHandler? Closing; + /// + public event IWindow.WindowEventHandler? Disposing; + /// public event IWindow.WindowEventHandler? Closed; @@ -1101,6 +1104,12 @@ protected virtual bool OnClosing() { if (result) Glfw3.DebugContext.Log(LogLevel.Debug, "Window closure cancelled by event handler.", prefix: LogId); return result; } + + /// + protected virtual void OnDisposing() { + Glfw3.DebugContext.Log(LogLevel.Info, "Window disposing...", prefix: LogId); + Disposing?.Invoke(this); + } /// protected virtual void OnClosed() { @@ -1129,6 +1138,9 @@ private void Dispose(bool disposing) { try { if (_disposed) return; + // Disposing + OnDisposing(); + // Dispose managed state (managed objects) if (disposing) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector.csproj b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector.csproj new file mode 100644 index 0000000..d5ceb40 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector.csproj @@ -0,0 +1,25 @@ + + + + + + Catalyst.Modules.Crystal.Connectors + Catalyst.Modules.Crystal.Glfw3OpenGLSurfaceConnector + true + + + CatalystUI Crystal – Glfw3OpenGLSurfaceConnector + 1.0.0 + alpha.1 + CatalystUI LLC + Glfw3 to OpenGL API for the Crystal subset of modules provided by the CatalystUI library. + CatalystUI,Crystal,glfw3,glfw,opengl,gl,ui,gui,graphics,visual,window + + + + + + + + + \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Extensions/CatalystAppBuilderExtensions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Extensions/CatalystAppBuilderExtensions.cs new file mode 100644 index 0000000..2936246 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Extensions/CatalystAppBuilderExtensions.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +// ReSharper disable once CheckNamespace + +using Catalyst.Modules.Crystal.Connectors; + +namespace Catalyst.Builders.Extensions { + + /// + /// Builder extensions for the . + /// + public static class CatalystAppBuilderExtensions { + + /// + /// Adds the Crystal-based Glfw3 to OpenGL surface connector to the . + /// + /// + /// + /// The Crystal-based Glfw3 to OpenGL surface connector adds the following to your CatalystUI application: + /// + /// + /// + /// Click on any of the above links to learn more about each component. + /// + /// + /// The to add the connector to. + /// The with the Glfw3 to OpenGL surface connector added. + public static CatalystAppBuilder AddCrystalGlfw3OpenGLSurfaceConnector(this CatalystAppBuilder builder) { + Glfw3OpenGLSurfaceConnector connector = new(); + ModelRegistry.RegisterConnector(connector); + return builder; + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLRenderTarget.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLRenderTarget.cs new file mode 100644 index 0000000..91f2c34 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLRenderTarget.cs @@ -0,0 +1,151 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Modules.Crystal.Glfw3; +using Catalyst.Supplementary; +using System; + +namespace Catalyst.Modules.Crystal.Connectors { + + /// + /// A Crystal implementation for a render target + /// between a Glfw3 window and an OpenGL renderer. + /// + public class Glfw3OpenGLRenderTarget : IRenderTarget { + + /// + public event IRenderTarget.RenderTargetEventHandler? Updated; + + /// + public event IRenderTarget.RenderTargetEventHandler? Destroying; + + /// + /// The window associated with the render target. + /// + protected readonly IWindow _window; + + /// + /// Internal reference for . + /// + private nint[]? _nativeHandle; + + /// + public nint[] NativeHandle { + get { + if (_nativeHandle == null) { + // cache it for performance + if (SystemDetector.IsSystem()) { + _nativeHandle = [_window.NativeHandle[0]]; + } else if (SystemDetector.IsSystem()) { + _nativeHandle = [_window.NativeHandle[0]]; + } else if (SystemDetector.IsSystem()) { + throw new NotImplementedException(); + } else { + _nativeHandle = []; + } + } + return _nativeHandle; + } + } + + /// + public double X => _window.X; + + /// + public double Y => _window.Y; + + /// + public uint Width => _window.Width; + + /// + public uint Height => _window.Height; + + /// + public double SurfaceX => _window.Display?.X ?? 0; + + /// + public double SurfaceY => _window.Display?.Y ?? 0; + + /// + public double SurfaceWidth => _window.Display?.Width ?? 0; + + /// + public double SurfaceHeight => _window.Display?.Height ?? 0; + + /// + public double SurfacePpi => _window.Display?.PixelsPerInch ?? 96.0; + + /// + public double SurfaceScalingFactor => _window.Display?.ScalingFactor ?? 1.0; + + /// + public bool IsVisible => _window.IsVisible; + + /// + public bool IsEnabled { get; set; } + + // Cached delegates for callbacks for performance +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + protected readonly IWindow.WindowEventHandler _handleWindowUpdated; + protected readonly IWindow.WindowEventHandler _handleWindowDisposing; +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Constructs a new . + /// + /// The associated with the render target. + public Glfw3OpenGLRenderTarget(GlfwWindow window) { + // Fields + _window = window; + _handleWindowUpdated = HandleWindowUpdated; + _handleWindowDisposing = HandleWindowDisposing; + + // Properties + IsEnabled = true; + + // Attach events + _window.Refresh += _handleWindowUpdated; + _window.Redraw += _handleWindowUpdated; + _window.Resized += _handleWindowUpdated; + _window.Repositioned += _handleWindowUpdated; + _window.Maximized += _handleWindowUpdated; + _window.Minimized += _handleWindowUpdated; + _window.Focused += _handleWindowUpdated; + _window.Shown += _handleWindowUpdated; + _window.Disposing += _handleWindowDisposing; + } + + /// + /// Used as a catch-all for window updates that require a render target update. + /// + /// The window that was updated. + protected virtual void HandleWindowUpdated(IWindow window) { + OnUpdated(); + } + + /// + protected virtual void HandleWindowDisposing(IWindow window) { + OnDestroying(); + } + + /// + protected virtual void OnUpdated() { + Updated?.Invoke(this); + } + + /// + protected virtual void OnDestroying() { + Destroying?.Invoke(this); + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLSurfaceConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLSurfaceConnector.cs new file mode 100644 index 0000000..c56dc22 --- /dev/null +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector/Glfw3OpenGLSurfaceConnector.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Modules.Crystal.Glfw3; +using System; + +namespace Catalyst.Modules.Crystal.Connectors { + + /// + /// The Crystal implementation for a surface connector between + /// a Glfw3 window and an OpenGL renderer. + /// + public sealed class Glfw3OpenGLSurfaceConnector : ISurfaceConnector { + + /// + public IRenderTarget CreateRenderTarget(IWindow window) { + if (window is not GlfwWindow glfwWindow) throw new ArgumentException("The provided window is not a GlfwWindow.", nameof(window)); + return new Glfw3OpenGLRenderTarget(glfwWindow); + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs index ae5b830..9832b74 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/NativeConnectors/OpenGLMacNativeConnector.cs @@ -10,6 +10,7 @@ // ------------------------------------------------------------------------------------------------- using Catalyst.Supplementary; +using Catalyst.Threading; using System; using System.Runtime.InteropServices; @@ -30,41 +31,52 @@ public nint GetProcAddress(string name) { /// public nint CreateContext(OpenGL gl, IRenderTarget target) { - // Fetch classes and selectors - nint fmtClass = MacOpenGLImports.objc_getClass("NSOpenGLPixelFormat"); - nint ctxClass = MacOpenGLImports.objc_getClass("NSOpenGLContext"); - nint allocSel = MacOpenGLImports.sel_registerName("alloc"); - nint initAttrSel = MacOpenGLImports.sel_registerName("initWithAttributes:"); - nint initFormatSel = MacOpenGLImports.sel_registerName("initWithFormat:shareContext:"); + // MacOS requires us to do this on the Main thread. + if (!ThreadDelegateDispatcher.IsMainThreadCaptured) throw new RequiresMainThreadException(nameof(OpenGLMacNativeConnector), nameof(CreateContext)); + nint context = 0; nint makeCurrentSel = MacOpenGLImports.sel_registerName("makeCurrentContext"); - - // Attribute list for OpenGL 3.2 Core profile with 24-bit color and depth - int[] attribs = [ - 99, // NSOpenGLPFAOpenGLProfile - 0x3200, // NSOpenGLProfileVersion3_2Core - 5, // NSOpenGLPFADoubleBuffer - 8, 24, // NSOpenGLPFAColorSize - 12, 24, // NSOpenGLPFADepthSize - 73, // NSOpenGLPFAAccelerated - 0 // terminator - ]; - - // Create the pixel format object - nint fmtAlloc = MacOpenGLImports.objc_msgSend(fmtClass, allocSel); - nint pixelFormat; - fixed (int* attribPtr = attribs) { - pixelFormat = MacOpenGLImports.objc_msgSend(fmtAlloc, initAttrSel, (nint) attribPtr); - } - if (pixelFormat == 0) { - throw new InvalidOperationException("Failed to create NSOpenGLPixelFormat."); - } - - // Create an OpenGL context - nint ctxAlloc = MacOpenGLImports.objc_msgSend(ctxClass, allocSel); - nint context = MacOpenGLImports.objc_msgSend(ctxAlloc, initFormatSel, pixelFormat, 0); - if (context == 0) { - throw new InvalidOperationException("Failed to create NSOpenGLContext."); - } + _ = ThreadDelegateDispatcher.MainThreadDispatcher.Execute(() => { + // Fetch classes and selectors + nint fmtClass = MacOpenGLImports.objc_getClass("NSOpenGLPixelFormat"); + nint ctxClass = MacOpenGLImports.objc_getClass("NSOpenGLContext"); + nint allocSel = MacOpenGLImports.sel_registerName("alloc"); + nint initAttrSel = MacOpenGLImports.sel_registerName("initWithAttributes:"); + nint initFormatSel = MacOpenGLImports.sel_registerName("initWithFormat:shareContext:"); + + // Attribute list for OpenGL 3.2 Core profile with 24-bit color and depth + int[] attribs = [ + 99, // NSOpenGLPFAOpenGLProfile + 0x3200, // NSOpenGLProfileVersion3_2Core + 5, // NSOpenGLPFADoubleBuffer + 8, 24, // NSOpenGLPFAColorSize + 12, 24, // NSOpenGLPFADepthSize + 73, // NSOpenGLPFAAccelerated + 0 // terminator + ]; + + // Create the pixel format object + nint fmtAlloc = MacOpenGLImports.objc_msgSend(fmtClass, allocSel); + nint pixelFormat; + fixed (int* attribPtr = attribs) { + pixelFormat = MacOpenGLImports.objc_msgSend(fmtAlloc, initAttrSel, (nint) attribPtr); + } + if (pixelFormat == 0) { + throw new InvalidOperationException("Failed to create NSOpenGLPixelFormat."); + } + + // Create an OpenGL context + nint ctxAlloc = MacOpenGLImports.objc_msgSend(ctxClass, allocSel); + context = MacOpenGLImports.objc_msgSend(ctxAlloc, initFormatSel, pixelFormat, 0); + if (context == 0) { + throw new InvalidOperationException("Failed to create NSOpenGLContext."); + } + + // Set the context's view to the target's native handle + nint contentViewSel = MacOpenGLImports.sel_registerName("contentView"); + nint view = MacOpenGLImports.objc_msgSend(target.NativeHandle[0], contentViewSel); + nint setViewSel = MacOpenGLImports.sel_registerName("setView:"); + MacOpenGLImports.objc_msgSend(context, setViewSel, view); + }, wait: true); // Make the context current MacOpenGLImports.objc_msgSend(context, makeCurrentSel); diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs index 5fe2ad6..588d307 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs @@ -17,6 +17,7 @@ using Silk.NET.OpenGL; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Text; using System.Threading; @@ -28,6 +29,29 @@ namespace Catalyst.Modules.Crystal.OpenGL.Renderer { /// public class OpenGLRenderer : IRenderer { + /// + /// The minimum rendering rate for the renderer in milliseconds. + /// + /// + /// Prevents lockups of the thread when the + /// renderer may not be responding. + /// + public const ushort MINIMUM_POLL_RATE = 3000; + + /// + /// Precise timing depends on the system timer's precision. In + /// worst-case scenarios, Windows is the slowest around + /// ~15.6 MS. We only use the system timer to wait + /// for periods longer than 20 MS or more, than swap + /// to a more precise waiting method below that time period. + /// + public const int PRECISE_TIMING_BUFFER = 16; + + /// + /// The default clear color for the renderer. + /// + public static readonly Color128 DEFAULT_CLEAR_COLOR = Color128.WHITE; + /// public event IRenderer.RendererErroredEventHandler? Errored; @@ -104,18 +128,18 @@ public virtual bool UseVSync { return; } if (_useVSync) { - OpenGL.DebugContext.Log(LogLevel.Verbose, "Enabling VSync...", prefix: LogId); + OpenGL.DebugContext.Log(LogLevel.Debug, "Enabling VSync...", prefix: LogId); if (!OpenGLNative.TrySetSwapInterval(1)) { OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to enable VSync.", prefix: LogId); } else { - OpenGL.DebugContext.Log(LogLevel.Verbose, "VSync enabled.", prefix: LogId); + OpenGL.DebugContext.Log(LogLevel.Debug, "VSync enabled.", prefix: LogId); } } else { - OpenGL.DebugContext.Log(LogLevel.Verbose, "Disabling VSync...", prefix: LogId); + OpenGL.DebugContext.Log(LogLevel.Debug, "Disabling VSync...", prefix: LogId); if (!OpenGLNative.TrySetSwapInterval(0)) { OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to disable VSync.", prefix: LogId); } else { - OpenGL.DebugContext.Log(LogLevel.Verbose, "VSync disabled.", prefix: LogId); + OpenGL.DebugContext.Log(LogLevel.Debug, "VSync disabled.", prefix: LogId); } } }, wait: true); @@ -160,10 +184,23 @@ public virtual bool ShouldMeasureFrameRate { /// protected uint _previousTargetHeight; + /// + /// Used to signal polling resets. + /// + protected readonly ManualResetEvent _resetPollEventHandle; + + /// + /// Used to track the current state of the poll event handle. + /// + protected bool _resetPollEventHandleState; + // Cached delegates for callbacks for performance #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + protected readonly Action _cachedActionRender; protected readonly ThreadDelegateDispatcher.DispatcherEventHandler _handlePreExecute; protected readonly ThreadDelegateDispatcher.DispatcherQueueEventHandler _handleDelegateEnqueued; + protected readonly IRenderTarget.RenderTargetEventHandler _handleTargetUpdated; + protected readonly IRenderTarget.RenderTargetEventHandler _handleTargetDestroying; #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member /// @@ -188,8 +225,13 @@ public OpenGLRenderer(RendererOptions options) { _shouldMeasureFrameRate = options.ShouldMeasureFrameRate; _previousTargetWidth = 0; _previousTargetHeight = 0; + _resetPollEventHandle = new(false); + _resetPollEventHandleState = false; + _cachedActionRender = Render; _handlePreExecute = HandlePreExecute; _handleDelegateEnqueued = HandleDelegateEnqueued; + _handleTargetUpdated = HandleTargetUpdated; + _handleTargetDestroying = HandleTargetDestroying; _disposed = false; _lock = new(LockRecursionPolicy.SupportsRecursion); @@ -274,13 +316,18 @@ public OpenGLRenderer(RendererOptions options) { } /// - public void SetTarget(IRenderTarget? target) { + public virtual void SetTarget(IRenderTarget? target) { ObjectDisposedException.ThrowIf(_disposed, this); if (_target == target) return; _ = Dispatcher.Execute(() => { // Delete the existing context if one exists if (_target != null) { - OpenGL.DebugContext.Log(LogLevel.Debug, $"Deleting existing OpenGL context for target {_target.GetType().Name}...", prefix: LogId); + // Detach events + _target.Updated -= _handleTargetUpdated; + _target.Destroying -= _handleTargetDestroying; + + // Delete the context + OpenGL.DebugContext.Log(LogLevel.Info, $"Deleting existing OpenGL context for target {_target.GetType().Name}...", prefix: LogId); if (!OpenGLNative.TryDeleteContext(OpenGLContext, _target)) { OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to delete existing OpenGL context.", prefix: LogId); } else { @@ -291,7 +338,7 @@ public void SetTarget(IRenderTarget? target) { // If we don't have a new target to set, we're all done if (target == null) { - OpenGL.DebugContext.Log(LogLevel.Debug, "No new render target provided; renderer is now inactive.", prefix: LogId); + OpenGL.DebugContext.Log(LogLevel.Info, "No new render target provided; renderer is now inactive.", prefix: LogId); _target = null; return; } @@ -300,7 +347,7 @@ public void SetTarget(IRenderTarget? target) { _target = target; // Create and bind the OpenGL context - OpenGL.DebugContext.Log(LogLevel.Debug, $"Creating OpenGL context for target {target.GetType().Name}...", prefix: LogId); + OpenGL.DebugContext.Log(LogLevel.Info, $"Creating OpenGL context for target {target.GetType().Name}...", prefix: LogId); OpenGLContext = OpenGLNative.CreateContext(OpenGL, _target); if (OpenGLContext == 0) throw new RendererException("Failed to create OpenGL context."); if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target)) throw new RendererException("Failed to make OpenGL context current."); @@ -309,30 +356,60 @@ public void SetTarget(IRenderTarget? target) { // Set the swap interval UseVSync = _useVSync; -#if DEBUG + // Determine debug extensions + OpenGL.DebugContext.Log(LogLevel.Verbose, "Discovering OpenGL extensions...", prefix: LogId); + GL gl = OpenGL.Api; + int numExt = gl.GetInteger(GLEnum.NumExtensions); + for (uint i = 0; i < numExt; i++) { + string ext = gl.GetStringS(GLEnum.Extensions, i); + OpenGL.DebugContext.Log(LogLevel.Verbose, $"OpenGL Extension: {ext}", prefix: LogId); + } + // Mark OpenGL debug info unsafe { - GL gl = OpenGL.Api; - if (gl.IsExtensionPresent("GL_ARB_debug_output")) { + if (gl.IsExtensionPresent("GL_ARB_debug_output") || gl.IsExtensionPresent("GL_KHR_debug") || (gl.GetInteger(GLEnum.ContextFlags) & (int) GLEnum.ContextFlagDebugBit) != 0) { gl.Enable(EnableCap.DebugOutput); gl.Enable(EnableCap.DebugOutputSynchronous); // TODO: small memory leak? gl.DebugMessageCallback((source, type, id, severity, length, message, userParam) => { - if (severity == (GLEnum) DebugSeverity.DebugSeverityNotification) return; // Ignore notifications - if (severity == (GLEnum) DebugSeverity.DebugSeverityLow) return; // Ignore low-severity hints ReadOnlySpan span = new((void*) message, length); - OnErrored(new(Encoding.UTF8.GetString(span))); + string log = Encoding.UTF8.GetString(span); + if (severity == (GLEnum) DebugSeverity.DebugSeverityNotification) { + OpenGL.DebugContext.Log(LogLevel.Verbose, $"OpenGL: {log}"); + return; + } + if (severity == (GLEnum) DebugSeverity.DebugSeverityLow) { + OpenGL.DebugContext.Log(LogLevel.Debug, $"OpenGL: {log}"); + return; + } + OnErrored(new(log)); }, (void*) 0); gl.DebugMessageControl(DebugSource.DontCare, DebugType.DontCare, DebugSeverity.DontCare, 0, null, true); } - } -#endif + + // Attach events + _target.Updated += _handleTargetUpdated; + _target.Destroying += _handleTargetDestroying; + + // Check for errors + GLEnum err = gl.GetError(); + if (err != GLEnum.NoError) { + OpenGL.DebugContext.Log(LogLevel.Warning, $"OpenGL reported an error when setting the target: {err}", prefix: LogId); + } + + // Check framebuffer status + GLEnum status = gl.CheckFramebufferStatus(GLEnum.Framebuffer); + if (status != GLEnum.FramebufferComplete) { + OpenGL.DebugContext.Log(LogLevel.Warning, $"OpenGL framebuffer is not complete: {status}", prefix: LogId); + } else { + OpenGL.DebugContext.Log(LogLevel.Debug, "OpenGL framebuffer is complete.", prefix: LogId); + } }, wait: true); } /// - public bool TryRegisterLayer(IRenderLayer layer) { + public virtual bool TryRegisterLayer(IRenderLayer layer) { ObjectDisposedException.ThrowIf(_disposed, this); bool result = false; _ = Dispatcher.Execute(() => { @@ -349,7 +426,7 @@ public bool TryRegisterLayer(IRenderLayer layer) { } /// - public void UnregisterLayer(IRenderLayer layer) { + public virtual void UnregisterLayer(IRenderLayer layer) { ObjectDisposedException.ThrowIf(_disposed, this); _ = Dispatcher.Execute(() => { if (!_layers.Contains(layer)) { @@ -362,9 +439,10 @@ public void UnregisterLayer(IRenderLayer layer) { } /// - public void Render() { + public virtual void Render() { if (_disposed) return; - if (_target is not { IsEnabled: true }) return; + if (_target == null) return; + if (!_target.IsVisible || !_target.IsEnabled) return; if (_target.Width == 0 || _target.Height == 0) return; if (OpenGLContext == nint.Zero) return; if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target)) { @@ -389,7 +467,7 @@ public void Render() { // If there's no layers, clear to white and present // Otherwise, loop through the layers and pass control if (_layers.Count == 0) { - gl.ClearColor(Color128.WHITE.R, Color128.WHITE.G, Color128.WHITE.B, Color128.WHITE.A); + gl.ClearColor(DEFAULT_CLEAR_COLOR.R, DEFAULT_CLEAR_COLOR.G, DEFAULT_CLEAR_COLOR.B, DEFAULT_CLEAR_COLOR.A); gl.Clear((uint) ClearBufferMask.ColorBufferBit); } else { for (int i = 0; i < _layers.Count; i++) { @@ -403,6 +481,12 @@ public void Render() { } } + // Check for rendering errors + GLEnum err = gl.GetError(); + if (err != GLEnum.NoError) { + OpenGL.DebugContext.Log(LogLevel.Warning, $"OpenGL reported an error during rendering: {err}", prefix: LogId); + } + // Swap the buffers if (!OpenGLNative.TrySwapBuffers(_target)) { OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to swap buffers after rendering.", prefix: LogId); @@ -413,7 +497,7 @@ public void Render() { } /// - public void Clear(Color128 color, bool depth) { + public virtual void Clear(Color128 color, bool depth) { ObjectDisposedException.ThrowIf(_disposed, this); _ = Dispatcher.Execute(() => { if (_disposed) return; @@ -447,12 +531,74 @@ public void Clear(Color128 color, bool depth) { /// protected virtual void HandlePreExecute(ThreadDelegateDispatcher dispatcher) { - // ... + if (TargetFrameRate == 0) return; // If passive rendering, no need to handle + if (TargetFrameRate != ushort.MaxValue) { + // Perform a render and calculate render time + long startTicks = Stopwatch.GetTimestamp(); + Render(); + long endTicks = Stopwatch.GetTimestamp(); + + // Calculate target ticks per frame, then calculate remaining ticks + long targetTicks = Stopwatch.Frequency / _targetFrameRate; + long elapsedTicks = endTicks - startTicks; + long remainingTicks = targetTicks - elapsedTicks; + + // If rendering didn't take the full time, wait the remaining time + if (remainingTicks > 0) { + // Determine a rough ms value and wait for that + int remainingMs = (int) Math.Max((remainingTicks * 1000L / Stopwatch.Frequency) - PRECISE_TIMING_BUFFER, 0); + if (remainingMs > 0) { + _resetPollEventHandle.WaitOne(remainingMs); + if (_resetPollEventHandleState) { + _resetPollEventHandle.Reset(); + _resetPollEventHandleState = false; + } + } + + // Then wait using a more precise method + remainingTicks = (targetTicks + startTicks) - Stopwatch.GetTimestamp(); + if (remainingTicks > 0) { + long spinTicks = Stopwatch.GetTimestamp() + remainingTicks; + while (!_resetPollEventHandleState) { + long now = Stopwatch.GetTimestamp(); + if (now >= spinTicks) break; + long ticksLeft = spinTicks - now; + if (ticksLeft > Stopwatch.Frequency * 0.001) { // > 1ms + Thread.SpinWait(50); + } else if (ticksLeft > Stopwatch.Frequency * 0.0005) { // 0.5–1ms + Thread.Yield(); + } else { + // hot spin + } + } + } + } + + // Reset the poll event if it was set + if (_resetPollEventHandleState) { + _resetPollEventHandle.Reset(); + _resetPollEventHandleState = false; + } + } else { + Render(); + } } /// protected virtual void HandleDelegateEnqueued(ThreadDelegateDispatcher dispatcher, Delegate @delegate) { - // ... + _resetPollEventHandle.Set(); + _resetPollEventHandleState = true; + } + + /// + protected virtual void HandleTargetUpdated(IRenderTarget target) { + // TODO: Linux check (it doesn't like us blocking the window thread for rendering...) + _ = Dispatcher.Execute(_cachedActionRender, wait: true); + } + + /// + protected virtual void HandleTargetDestroying(IRenderTarget target) { + SetTarget(null); } /// diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs index 4d393ff..4dd0fc6 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs @@ -79,6 +79,9 @@ public unsafe class SdlWindow : IWindow { /// public event IWindow.WindowClosingEventHandler? Closing; + /// + public event IWindow.WindowEventHandler? Disposing; + /// public event IWindow.WindowEventHandler? Closed; @@ -909,6 +912,12 @@ protected virtual bool OnClosing() { return result; } + /// + protected virtual void OnDisposing() { + Sdl2.DebugContext.Log(LogLevel.Info, "Window disposing...", prefix: LogId); + Disposing?.Invoke(this); + } + /// protected virtual void OnClosed() { Sdl2.DebugContext.Log(LogLevel.Info, "Window closed.", prefix: LogId); @@ -936,6 +945,9 @@ private void Dispose(bool disposing) { try { if (_disposed) return; + // Disposing + OnDisposing(); + // Dispose managed state (managed objects) if (disposing) { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract From 620c0d422b97c9cf47ea4d612412e54cf10c75b6 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Mon, 10 Nov 2025 21:30:11 -0700 Subject: [PATCH 08/14] Add framerate monitoring In theory, the basics for a renderer should now be complete. --- .../Renderer/RendererOptions.cs | 23 ++++- .../Renderer/OpenGLRenderer.cs | 98 ++++++++++++++++--- 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs index d595b2a..4dcfb81 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs @@ -30,7 +30,7 @@ namespace Catalyst.Modules.Crystal { public IRenderLayer[] Layers { get; init; } = []; /// - public bool UseVSync { get; init; } = true; + public bool UseVSync { get; init; } = false; /// public ushort TargetFrameRate { get; init; } = 0; @@ -45,6 +45,20 @@ namespace Catalyst.Modules.Crystal { /// public IRenderer.RendererEventHandler? InitializedHandler { get; init; } = null; + /// + /// Invoked after the first frame is renderer for + /// each assigned target. + /// + /// + /// The event is only invoked once per target, + /// after which it is never invoked again + /// unless another target is assigned, in + /// which case it will be invoked once + /// for that target after its first frame + /// is rendered. + /// + public IRenderer.RendererEventHandler? FirstFrameHandler { get; init; } = null; + /// /// Constructs a new . /// @@ -55,14 +69,16 @@ namespace Catalyst.Modules.Crystal { /// The renderer's target frame rate. /// to measure the frame rate; otherwise, . /// An optional handler invoked when the renderer is initialized. + /// An optional handler invoked after the first frame is rendered. public RendererOptions( ThreadDelegateDispatcher? dispatcher = null, IRenderTarget? target = null, IRenderLayer[]? layers = null, - bool useVSync = true, + bool useVSync = false, ushort targetFrameRate = 0, bool shouldMeasureFrameRate = false, - IRenderer.RendererEventHandler? initializedHandler = null + IRenderer.RendererEventHandler? initializedHandler = null, + IRenderer.RendererEventHandler? firstFrameHandler = null ) { Dispatcher = dispatcher; Target = target; @@ -71,6 +87,7 @@ public RendererOptions( TargetFrameRate = targetFrameRate; ShouldMeasureFrameRate = shouldMeasureFrameRate; InitializedHandler = initializedHandler; + FirstFrameHandler = firstFrameHandler; } /// diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs index 588d307..613d8fe 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs @@ -9,6 +9,7 @@ // For full terms, see the LICENSE and NOTICE files in the project root. // ------------------------------------------------------------------------------------------------- +using Catalyst.Collections; using Catalyst.Debugging; using Catalyst.Domains; using Catalyst.Layers; @@ -47,6 +48,11 @@ public class OpenGLRenderer : IRenderer { /// public const int PRECISE_TIMING_BUFFER = 16; + /// + /// The number of samples used to calculate the frame rate and delta time. + /// + public const int FRAME_RATE_SAMPLE_SIZE = 24; + /// /// The default clear color for the renderer. /// @@ -157,16 +163,8 @@ public virtual ushort TargetFrameRate { set => throw new NotImplementedException(); } - /// - /// Internal reference for . - /// - protected volatile bool _shouldMeasureFrameRate; - /// - public virtual bool ShouldMeasureFrameRate { - get => _shouldMeasureFrameRate; - set => throw new NotImplementedException(); - } + public virtual bool ShouldMeasureFrameRate { get; set; } /// public virtual double AvgFrameRate { get; protected set; } @@ -194,8 +192,25 @@ public virtual bool ShouldMeasureFrameRate { /// protected bool _resetPollEventHandleState; + /// + /// The stopwatch used for frame rate measurement. + /// + protected readonly Stopwatch _frameRateStopWatch; + + /// + /// The last time the frame rate was reported. + /// + protected DateTime _frameRateLastReportTime; + + /// + /// The samples used for average frame rate calculation. + /// + protected readonly StaticArrayQueue _frameRateSamples; + // Cached delegates for callbacks for performance #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + protected bool _firstFrame; + protected readonly IRenderer.RendererEventHandler? _firstFrameHandler; protected readonly Action _cachedActionRender; protected readonly ThreadDelegateDispatcher.DispatcherEventHandler _handlePreExecute; protected readonly ThreadDelegateDispatcher.DispatcherQueueEventHandler _handleDelegateEnqueued; @@ -222,11 +237,15 @@ public OpenGLRenderer(RendererOptions options) { _layers = []; // handled later _useVSync = options.UseVSync; _targetFrameRate = options.TargetFrameRate; - _shouldMeasureFrameRate = options.ShouldMeasureFrameRate; _previousTargetWidth = 0; _previousTargetHeight = 0; _resetPollEventHandle = new(false); _resetPollEventHandleState = false; + _frameRateStopWatch = new(); + _frameRateLastReportTime = DateTime.UtcNow; + _frameRateSamples = new(FRAME_RATE_SAMPLE_SIZE); + _firstFrame = true; + _firstFrameHandler = options.FirstFrameHandler; _cachedActionRender = Render; _handlePreExecute = HandlePreExecute; _handleDelegateEnqueued = HandleDelegateEnqueued; @@ -248,6 +267,11 @@ public OpenGLRenderer(RendererOptions options) { } else { Dispatcher = options.Dispatcher; } + // ReSharper disable VirtualMemberCallInConstructor + ShouldMeasureFrameRate = options.ShouldMeasureFrameRate; + AvgDeltaTime = 0; + AvgFrameRate = 0; + // ReSharper enable VirtualMemberCallInConstructor // Wait for other processes if they are initializing using Mutex mutex = new(true, "Global\\CatalystUI_OpenGL_Lock", out bool newMutex); @@ -405,6 +429,9 @@ public virtual void SetTarget(IRenderTarget? target) { } else { OpenGL.DebugContext.Log(LogLevel.Debug, "OpenGL framebuffer is complete.", prefix: LogId); } + + // Mark first frame + _firstFrame = true; }, wait: true); } @@ -442,7 +469,7 @@ public virtual void UnregisterLayer(IRenderLayer layer) { public virtual void Render() { if (_disposed) return; if (_target == null) return; - if (!_target.IsVisible || !_target.IsEnabled) return; + if (!_target.IsEnabled) return; if (_target.Width == 0 || _target.Height == 0) return; if (OpenGLContext == nint.Zero) return; if (!OpenGLNative.TryMakeContextCurrent(OpenGLContext, _target)) { @@ -454,6 +481,15 @@ public virtual void Render() { // Frame start OnFrameStart(); + // Update the frame time + if (ShouldMeasureFrameRate) { + UpdateMeasuredFrameRate(); + if (DateTime.UtcNow - _frameRateLastReportTime >= TimeSpan.FromSeconds(1)) { + OpenGL.DebugContext.Log(LogLevel.Verbose, $"Reported Frame Rate: {AvgFrameRate:F2} FPS, Average Delta Time: {AvgDeltaTime * 1000.0:F2} ms", prefix: LogId); + _frameRateLastReportTime = DateTime.UtcNow; + } + } + // Update the viewport uint targetWidth = _target.Width; uint targetHeight = _target.Height; @@ -492,6 +528,9 @@ public virtual void Render() { OpenGL.DebugContext.Log(LogLevel.Warning, "Failed to swap buffers after rendering.", prefix: LogId); } + // Handle first frame + if (_firstFrame) OnFirstFrame(); + // Frame end OnFrameEnd(); } @@ -529,6 +568,37 @@ public virtual void Clear(Color128 color, bool depth) { }, wait: true); } + /// + /// Updates the measured frame rate and delta time. + /// + protected virtual void UpdateMeasuredFrameRate() { + // Update frame time history + if (_frameRateSamples.IsFull) _frameRateSamples.Dequeue(); + ref double lastFrameTime = ref _frameRateSamples.PeekEnqueue(); + lastFrameTime = (double) _frameRateStopWatch.ElapsedTicks / Stopwatch.Frequency; + _frameRateSamples.Enqueue(); + + // Apply exponential smoothing + if (AvgDeltaTime <= 0.0) AvgDeltaTime = lastFrameTime; + else AvgDeltaTime = AvgDeltaTime * (1.0 - 0.1) + lastFrameTime * 0.1; + + // Calculate the average frame-rate + double fpsSum = 0.0; + ushort fpsCount = 0; + double[] samples = _frameRateSamples.Items; + for (int i = 0; i < samples.Length; i++) { + double sample = samples[i]; + if (sample >= 0.0) { + fpsSum += sample; + fpsCount++; + } + } + AvgFrameRate = fpsCount > 0 && fpsSum > 0 ? 1.0 / (fpsSum / fpsCount) : 0.0; + + // Restart the stopwatch + _frameRateStopWatch.Restart(); + } + /// protected virtual void HandlePreExecute(ThreadDelegateDispatcher dispatcher) { if (TargetFrameRate == 0) return; // If passive rendering, no need to handle @@ -621,6 +691,12 @@ protected virtual void OnFrameEnd() { FrameEnd?.Invoke(this); } + /// + protected virtual void OnFirstFrame() { + _firstFrameHandler?.Invoke(this); + _firstFrame = false; + } + /// /// Disposes of the . /// From 3cc6f71bab35b51955ea7beb0c87ad393107ec75 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Tue, 11 Nov 2025 03:31:29 -0700 Subject: [PATCH 09/14] Add BasicWindow example project * Added a "quickstart" BasicWindow example project * Resolved get/init/set issues with WindowOptions & RendererOptions (no need for them to be read-only). --- .scripts/Setup.ps1 | 8 ++ CatalystUI/CatalystUI.sln | 9 ++ .../CatalystUI.Examples.BasicWindow.csproj | 23 ++++ .../Program.cs | 106 ++++++++++++++++++ .../Renderer/RendererOptions.cs | 18 +-- .../Window/WindowOptions.cs | 24 ++-- 6 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj create mode 100644 CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Program.cs diff --git a/.scripts/Setup.ps1 b/.scripts/Setup.ps1 index 992dc38..41e0c1e 100644 --- a/.scripts/Setup.ps1 +++ b/.scripts/Setup.ps1 @@ -111,6 +111,14 @@ $projectsList = @( ) PromptIgnore = $false Depends = @("Crystal.Glfw3", "Crystal.OpenGL") + }, + @{ + Module = "Catalyst.Examples.BasicWindow" + Projects = @( + @{ Folder = "Examples/BasicWindow"; Name = "CatalystUI.Examples.BasicWindow" } + ) + PromptIgnore = $false + Depends = @("Crystal.Glfw3", "Crystal.OpenGL", "Crystal.Glfw3OpenGLSurfaceConnector" ) } ) diff --git a/CatalystUI/CatalystUI.sln b/CatalystUI/CatalystUI.sln index ce4f002..2444580 100644 --- a/CatalystUI/CatalystUI.sln +++ b/CatalystUI/CatalystUI.sln @@ -46,6 +46,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector", "Modules\Crystal\CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector\CatalystUI.Modules.Crystal.Glfw3OpenGLSurfaceConnector.csproj", "{B03716DE-8B0C-4457-B71A-5E0A0D11325D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{4A4866FE-D5FE-4B0D-B5FB-A6C9090A137D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalystUI.Examples.BasicWindow", "Examples\CatalystUI.Examples.BasicWindow\CatalystUI.Examples.BasicWindow.csproj", "{CDBADF64-0FE0-4911-BDF8-07C4DB887337}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -120,6 +124,10 @@ Global {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Debug|Any CPU.Build.0 = Debug|Any CPU {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Release|Any CPU.ActiveCfg = Release|Any CPU {B03716DE-8B0C-4457-B71A-5E0A0D11325D}.Release|Any CPU.Build.0 = Release|Any CPU + {CDBADF64-0FE0-4911-BDF8-07C4DB887337}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDBADF64-0FE0-4911-BDF8-07C4DB887337}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDBADF64-0FE0-4911-BDF8-07C4DB887337}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDBADF64-0FE0-4911-BDF8-07C4DB887337}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {68F496AC-9438-40F1-9DF8-97363033D661} = {7EC51871-49A8-4991-BDAF-F43A4E9B8C9D} @@ -142,5 +150,6 @@ Global {532C59A0-43D2-44E9-B6ED-E88E6B8770F5} = {41BEF490-7005-4D10-9958-22D636F9DE38} {9CD307E2-FEDA-4C64-B2EE-00E6E196175C} = {41BEF490-7005-4D10-9958-22D636F9DE38} {B03716DE-8B0C-4457-B71A-5E0A0D11325D} = {41BEF490-7005-4D10-9958-22D636F9DE38} + {CDBADF64-0FE0-4911-BDF8-07C4DB887337} = {4A4866FE-D5FE-4B0D-B5FB-A6C9090A137D} EndGlobalSection EndGlobal diff --git a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj new file mode 100644 index 0000000..d39bdeb --- /dev/null +++ b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + Catalyst.Examples.BasicWindow + Catalyst.Examples.BasicWindow + + + + + + + + + + + + + + + diff --git a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Program.cs b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Program.cs new file mode 100644 index 0000000..1f67220 --- /dev/null +++ b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Program.cs @@ -0,0 +1,106 @@ +// ------------------------------------------------------------------------------------------------- +// CatalystUI Framework for .NET Core - https://catalystui.org/ +// Copyright (c) 2025 CatalystUI LLC. All rights reserved. +// +// This file is part of CatalystUI and is provided as part of an early-access release. +// Unauthorized commercial use, distribution, or modification is strictly prohibited. +// +// This software is not open source and is not publicly licensed. +// For full terms, see the LICENSE and NOTICE files in the project root. +// ------------------------------------------------------------------------------------------------- + +using Catalyst.Builders; +using Catalyst.Builders.Extensions; +using Catalyst.Debugging; +using Catalyst.Modules.Crystal; +using Catalyst.Supplementary; +using Catalyst.Threading; + +namespace Catalyst.Examples.BasicWindow { + + /// + /// Program entry point for the Basic Window example application. + /// + public static class Program { + + /// + /// The debug context for the application. + /// + private static DebugContext _debug; + + /// + /// Static constructor for . + /// + static Program() { + _debug = null!; + } + + /// + /// Main entry point for the Basic Window example application. + /// + /// The command-line arguments. + public static void Main(string[] args) { + new CatalystAppBuilder() +#if DEBUG + .UseCatalystDebug() +#endif + .AddCrystalGlfw3Module() + .AddCrystalGlfw3OpenGLSurfaceConnector() + .AddCrystalOpenGLModule() + .Build(Run); + } + + /// + /// The main execution method for the application. + /// + /// The Catalyst application instance. + public static void Run(CatalystApp app) { + // Debug context initialization + _debug = CatalystDebug.ForContext("BasicWindow"); + _debug.LogInfo("Hello, world!"); + + // Layer requests (Since we only include one set of modules, Catalyst will automatically provide the correct implementations) + IWindowLayer windowLayer = ModelRegistry.RequestLayer(); + ISurfaceConnector surfaceConnector = ModelRegistry.RequestConnector(); + IRendererLayer rendererLayer = ModelRegistry.RequestLayer(); + + // Construct the window + WindowOptions options = new(); + if (!SystemDetector.IsSystem()) { + // MacOS requires windowing to run on the main thread, + // but other systems allow a dedicated window thread. + options.Dispatcher = ThreadDelegateDispatcher.New("WindowThread"); + } + options.Hidden = true; // hide until first frame + using IWindow window = windowLayer.CreateWindow(options); + + // Construct the render target + IRenderTarget target = surfaceConnector.CreateRenderTarget(window); + + // Construct the renderer + using IRenderer renderer = rendererLayer.CreateRenderer(new() { + Dispatcher = ThreadDelegateDispatcher.New("RenderThread"), // all platforms support a dedicated render thread + Target = target, + + // Optional: Enable V-Sync + //UseVSync = true, + + // Optional: Enable frame-rate measuring + //TargetFrameRate = ushort.MaxValue, + //ShouldMeasureFrameRate = true, + + FirstFrameHandler = _ => { + // Show the window once the first frame is ready + // ReSharper disable AccessToDisposedClosure + if (window is { IsClosed: false, IsHidden: true }) window.Show(); + // ReSharper disable AccessToDisposedClosure + } + }); + + // Wait for the window to close + window.Wait(); + } + + } + +} \ No newline at end of file diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs index 4dcfb81..c049ea2 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs @@ -17,33 +17,33 @@ namespace Catalyst.Modules.Crystal { /// /// Represents options for constructing/configuring a renderer. /// - public readonly record struct RendererOptions : IOptions { + public record struct RendererOptions : IOptions { /// The renderer's thread dispatcher, or to use a captured . /// - public ThreadDelegateDispatcher? Dispatcher { get; init; } = null; + public ThreadDelegateDispatcher? Dispatcher { get; set; } = null; /// - public IRenderTarget? Target { get; init; } = null; + public IRenderTarget? Target { get; set; } = null; /// - public IRenderLayer[] Layers { get; init; } = []; + public IRenderLayer[] Layers { get; set; } = []; /// - public bool UseVSync { get; init; } = false; + public bool UseVSync { get; set; } = false; /// - public ushort TargetFrameRate { get; init; } = 0; + public ushort TargetFrameRate { get; set; } = 0; /// - public bool ShouldMeasureFrameRate { get; init; } = false; + public bool ShouldMeasureFrameRate { get; set; } = false; /// /// Invoked after the renderer is initialized and /// prepared to be created, but prior to the /// first frame being rendered. /// - public IRenderer.RendererEventHandler? InitializedHandler { get; init; } = null; + public IRenderer.RendererEventHandler? InitializedHandler { get; set; } = null; /// /// Invoked after the first frame is renderer for @@ -57,7 +57,7 @@ namespace Catalyst.Modules.Crystal { /// for that target after its first frame /// is rendered. /// - public IRenderer.RendererEventHandler? FirstFrameHandler { get; init; } = null; + public IRenderer.RendererEventHandler? FirstFrameHandler { get; set; } = null; /// /// Constructs a new . diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/WindowOptions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/WindowOptions.cs index f0634dc..842e4d8 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/WindowOptions.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Window/WindowOptions.cs @@ -17,45 +17,45 @@ namespace Catalyst.Modules.Crystal { /// /// Represents options for constructing/configuring a window. /// - public readonly record struct WindowOptions : IOptions { + public record struct WindowOptions : IOptions { /// The window's thread dispatcher, or to use a captured . /// - public ThreadDelegateDispatcher? Dispatcher { get; init; } = null; + public ThreadDelegateDispatcher? Dispatcher { get; set; } = null; /// - public uint Width { get; init; } = IWindow.DEFAULT_WIDTH; + public uint Width { get; set; } = IWindow.DEFAULT_WIDTH; /// - public uint Height { get; init; } = IWindow.DEFAULT_HEIGHT; + public uint Height { get; set; } = IWindow.DEFAULT_HEIGHT; /// - public string Title { get; init; } = IWindow.DEFAULT_TITLE; + public string Title { get; set; } = IWindow.DEFAULT_TITLE; /// - public bool Hidden { get; init; } = false; + public bool Hidden { get; set; } = false; /// - public bool Resizable { get; init; } = true; + public bool Resizable { get; set; } = true; /// - public bool Decorated { get; init; } = true; + public bool Decorated { get; set; } = true; /// - public WindowFullscreenMode FullscreenMode { get; init; } = WindowFullscreenMode.Windowed; + public WindowFullscreenMode FullscreenMode { get; set; } = WindowFullscreenMode.Windowed; /// - public ushort PollRate { get; init; } = 0; + public ushort PollRate { get; set; } = 0; /// - public WindowIcon[]? Icons { get; init; } = null; + public WindowIcon[]? Icons { get; set; } = null; /// /// Invoked after the window is initialized and /// prepared to be created, but prior to the /// actual construction of the window. /// - public IWindow.WindowEventHandler? InitializedHandler { get; init; } = null; + public IWindow.WindowEventHandler? InitializedHandler { get; set; } = null; /// /// Constructs a new . From f91991f3fdb47d01cea2afc961dc5ad45a0a5c37 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Tue, 11 Nov 2025 03:36:56 -0700 Subject: [PATCH 10/14] Add BasicWindow publishing --- .gitignore | 1 + .../CatalystUI.Examples.BasicWindow.csproj | 6 +++ .../PublishProfiles/PublishExamples.pubxml | 37 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml diff --git a/.gitignore b/.gitignore index 4ac3ded..5e21b14 100644 --- a/.gitignore +++ b/.gitignore @@ -644,6 +644,7 @@ nohup.out ########################### # CatalystUI Negotiations # ########################### +!**/*/Examples/**/PublishProfiles/PublishExamples.pubxml !**/*.Profiling/ !**/*.Profiling/*.Profiling.csproj !**/*.Profiling/Program.cs diff --git a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj index d39bdeb..c2ea755 100644 --- a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj +++ b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/CatalystUI.Examples.BasicWindow.csproj @@ -9,6 +9,12 @@ + + BasicWindow + + + BasicWindow.Debug + diff --git a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml new file mode 100644 index 0000000..35659c6 --- /dev/null +++ b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml @@ -0,0 +1,37 @@ + + + + + + + Exe + true + true + true + true + true + true + + + ConsoleApp + bin\Release\net9.0\_publish\ + + + win-x64 + + + osx-x64 + + + linux-x64 + + + + + + + + + + + \ No newline at end of file From ec1db488c2cd029aabb3925f4ce232e998d8e9c9 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Tue, 11 Nov 2025 19:24:21 -0700 Subject: [PATCH 11/14] Microoptimizations for threading * Ensured threads properly wait if there's no delegates instead of spin-looping. * Resolved excessive fetching of TargetFramerate in OpenGLRenderer (moved to use underlying value) * Resolved publish profiles not exporting the correct architectures (Arm64 vs x64) --- .../Core/CatalystUI.Threading/DelegateQueue.cs | 16 ++++++++++++++++ .../PublishProfiles/PublishExamples.pubxml | 16 ++++++++-------- .../Renderer/OpenGLRenderer.cs | 4 ++-- .../PublishProfiles/PublishProfiling.pubxml | 16 ++++++++-------- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs index 4565b38..8457996 100644 --- a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs +++ b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs @@ -73,6 +73,11 @@ public sealed class DelegateQueue : IDisposable { /// private readonly ManualResetEvent _queueFullWaitHandle; + /// + /// A wait handle that is signaled when a delegate is enqueued. + /// + private readonly ManualResetEvent _delegateEnqueuedWaitHandle; + /// /// The ID of the thread currently executing the queue. /// @@ -98,6 +103,7 @@ public DelegateQueue(int? size = null) { // Fields _queue = new(size.Value); _queueFullWaitHandle = new(false); + _delegateEnqueuedWaitHandle = new(true); _executingThreadId = -1; _disposed = false; _lock = new(); @@ -163,6 +169,7 @@ public bool Enqueue(Delegate @delegate, nint caller, nint parameters, out nint @ } entry.HasAwaiter = wait; _queue.Enqueue(); + _delegateEnqueuedWaitHandle.Set(); // Why would we not exit the lock here? // Because we're still in the process of enqueuing, @@ -202,7 +209,12 @@ public void Execute() { ObjectDisposedException.ThrowIf(_disposed, this); _lock.Enter(); try { + // Wait until there's at least one delegate to execute + _delegateEnqueuedWaitHandle.WaitOne(); + _delegateEnqueuedWaitHandle.Reset(); + // If there's nothing to execute, return immediately + // (shouldn't be possible, but just in case) if (_queue.IsEmpty) return; // Loop through the queue and execute each delegate @@ -283,6 +295,10 @@ private void Dispose(bool disposing) { // Dispose managed state (managed objects) if (disposing) { + // Dispose of the delegate queue wait handle + _delegateEnqueuedWaitHandle.Set(); + _delegateEnqueuedWaitHandle.Dispose(); + // Dispose of the underlying wait handles EnqueuedDelegate[] items = _queue.Items; for (int i = 0; i < items.Length; i++) { diff --git a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml index 35659c6..e08cdfe 100644 --- a/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml +++ b/CatalystUI/Examples/CatalystUI.Examples.BasicWindow/Properties/PublishProfiles/PublishExamples.pubxml @@ -16,14 +16,14 @@ ConsoleApp bin\Release\net9.0\_publish\ - - win-x64 - - - osx-x64 - - - linux-x64 + + <_Arch>$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()) + win-arm64 + win-x64 + osx-arm64 + osx-x64 + linux-arm64 + linux-x64 diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs index 613d8fe..dfc110e 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs @@ -601,8 +601,8 @@ protected virtual void UpdateMeasuredFrameRate() { /// protected virtual void HandlePreExecute(ThreadDelegateDispatcher dispatcher) { - if (TargetFrameRate == 0) return; // If passive rendering, no need to handle - if (TargetFrameRate != ushort.MaxValue) { + if (_targetFrameRate == 0) return; // If passive rendering, no need to handle + if (_targetFrameRate != ushort.MaxValue) { // Perform a render and calculate render time long startTicks = Stopwatch.GetTimestamp(); Render(); diff --git a/CatalystUI/Tooling/CatalystUI.Profiling/Properties/PublishProfiles/PublishProfiling.pubxml b/CatalystUI/Tooling/CatalystUI.Profiling/Properties/PublishProfiles/PublishProfiling.pubxml index 35659c6..e08cdfe 100644 --- a/CatalystUI/Tooling/CatalystUI.Profiling/Properties/PublishProfiles/PublishProfiling.pubxml +++ b/CatalystUI/Tooling/CatalystUI.Profiling/Properties/PublishProfiles/PublishProfiling.pubxml @@ -16,14 +16,14 @@ ConsoleApp bin\Release\net9.0\_publish\ - - win-x64 - - - osx-x64 - - - linux-x64 + + <_Arch>$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()) + win-arm64 + win-x64 + osx-arm64 + osx-x64 + linux-arm64 + linux-x64 From 6f445d1e0260b5cb64e0127990e09e9ab814a7af Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Tue, 11 Nov 2025 19:41:29 -0700 Subject: [PATCH 12/14] Resolve GitHub PR review changes * Resolved changes by Copilot * Resolved chnages by @thisisbrady * Resolved changes by @itznxthaniel --- .../Renderer/RendererOptions.cs | 2 +- .../Renderer/OpenGLRenderer.cs | 25 ++++++++--- .../Window/SdlWindow.cs | 42 ++++++++++++++++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs index c049ea2..7bc5e2b 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Core/Renderer/RendererOptions.cs @@ -46,7 +46,7 @@ public record struct RendererOptions : IOptions { public IRenderer.RendererEventHandler? InitializedHandler { get; set; } = null; /// - /// Invoked after the first frame is renderer for + /// Invoked after the first frame is rendered for /// each assigned target. /// /// diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs index dfc110e..00730ec 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.OpenGL/Renderer/OpenGLRenderer.cs @@ -37,13 +37,13 @@ public class OpenGLRenderer : IRenderer { /// Prevents lockups of the thread when the /// renderer may not be responding. /// - public const ushort MINIMUM_POLL_RATE = 3000; + public const ushort MINIMUM_RENDER_RATE = 3000; /// /// Precise timing depends on the system timer's precision. In /// worst-case scenarios, Windows is the slowest around /// ~15.6 MS. We only use the system timer to wait - /// for periods longer than 20 MS or more, than swap + /// for periods longer than 20 MS or more, then swap /// to a more precise waiting method below that time period. /// public const int PRECISE_TIMING_BUFFER = 16; @@ -89,7 +89,7 @@ public class OpenGLRenderer : IRenderer { public nint OpenGLContext { get; private set; } /// - /// Gets the OpenGL native connector for the renderer. + /// Gets or sets the OpenGL native connector for the renderer. /// /// The OpenGL native connector. public IOpenGLNativeConnector> OpenGLNative { get; private set; } @@ -160,7 +160,13 @@ public virtual bool UseVSync { /// public virtual ushort TargetFrameRate { get => _targetFrameRate; - set => throw new NotImplementedException(); + set { + ObjectDisposedException.ThrowIf(_disposed, this); + _targetFrameRate = value; + _ = Dispatcher.Execute(() => { + // Signal the poll event to reset any waits + }); + } } /// @@ -715,7 +721,16 @@ private void Dispose(bool disposing) { // Dispose managed state (managed objects) if (disposing) { - // ... + // Detach dispatcher events + Dispatcher.PreExecute -= _handlePreExecute; + Dispatcher.DelegateEnqueued -= _handleDelegateEnqueued; + + // Dispose render target and OpenGL context + if (OpenGLContext != nint.Zero && _target != null) { + OpenGLNative.TryDeleteContext(OpenGLContext, _target); + } + SetTarget(null); + OpenGL?.Dispose(); } // Dispose unmanaged state (unmanaged objects) diff --git a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs index 4dd0fc6..5983190 100644 --- a/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs +++ b/CatalystUI/Modules/Crystal/CatalystUI.Modules.Crystal.Sdl2/Window/SdlWindow.cs @@ -131,7 +131,14 @@ public unsafe class SdlWindow : IWindow { /// public virtual ushort PollRate { get => _pollRate; - set => throw new NotImplementedException(); + set { + ObjectDisposedException.ThrowIf(_disposed, this); + _pollRate = value; // set outside dispatcher to avoid deadlock + _ = Dispatcher.Execute(() => { + // simply enqueing a no-op will cause the poll rate to + // be re-evaluated due to the polling loop + }, wait: true); + } } /// @@ -150,7 +157,16 @@ public virtual ushort PollRate { /// public virtual string Title { get => _title; - set => throw new NotImplementedException(); + set { + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + byte* titlePtr = (byte*) SilkMarshal.StringToPtr(value); + Sdl.Api.SetWindowTitle((Window*) SdlHandle, titlePtr); + SilkMarshal.Free((nint) titlePtr); + _title = value; + Sdl2.DebugContext.Log(LogLevel.Debug, $"Window title set to '{value}'.", prefix: LogId); + }, wait: true); + } } /// @@ -613,7 +629,19 @@ public virtual void Hide() { /// public virtual void Close() { - throw new NotImplementedException(); + ObjectDisposedException.ThrowIf(_disposed, this); + _ = Dispatcher.Execute(() => { + Event evt = new() { + Type = (uint) EventType.Windowevent, + Window = new() { + Type = (uint) EventType.Windowevent, + Event = (byte) WindowEventID.Close, + WindowID = SdlId + } + }; + Sdl.Api.PushEvent(&evt); + Sdl2.DebugContext.Log(LogLevel.Debug, "Window close requested.", prefix: LogId); + }, wait: true); } /// @@ -645,9 +673,9 @@ public virtual void Wait() { /// Refreshes the backing fields for the window properties. /// /// - /// Since Glfw3 does not provide events for all window property changes, + /// Since Sdl2 does not provide events for all window property changes, /// the following method is provided to manually refresh all properties - /// from the underlying Glfw3 window state. It should be called whenever + /// from the underlying Sdl2 window state. It should be called whenever /// a property change is suspected that may not have triggered an event. /// protected virtual void RefreshProperties() { @@ -955,6 +983,10 @@ private void Dispose(bool disposing) { Dispatcher.PreExecute -= _handlePreExecute; Dispatcher.DelegateEnqueued -= _handleDelegateEnqueued; } + + // Dispose and destroy the sdl instance + if (SdlHandle != nint.Zero) Sdl?.Api.DestroyWindow((Window*) SdlHandle); + Sdl?.Dispose(); } // Dispose unmanaged state (unmanaged objects) From 0b4662e0631cf81a9d579f25fd62d0da6eb58c91 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Tue, 11 Nov 2025 20:22:18 -0700 Subject: [PATCH 13/14] [SEVERE] Resolve lockup when waiting for delegates to be enqueued * This was put inside the lock causing it to wait forever because delegates could never be enqueued :( --- CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs index 8457996..e4bc5e7 100644 --- a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs +++ b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs @@ -207,12 +207,14 @@ public bool Enqueue(Delegate @delegate, nint caller, nint parameters, out nint @ /// public void Execute() { ObjectDisposedException.ThrowIf(_disposed, this); + + // Wait until there's at least one delegate to execute + _delegateEnqueuedWaitHandle.WaitOne(); + _delegateEnqueuedWaitHandle.Reset(); + + // Enter the lock to begin execution _lock.Enter(); try { - // Wait until there's at least one delegate to execute - _delegateEnqueuedWaitHandle.WaitOne(); - _delegateEnqueuedWaitHandle.Reset(); - // If there's nothing to execute, return immediately // (shouldn't be possible, but just in case) if (_queue.IsEmpty) return; From b1ae0e95476359078c8c63ddb45269115268d549 Mon Sep 17 00:00:00 2001 From: FireController#1847 Date: Tue, 11 Nov 2025 20:33:41 -0700 Subject: [PATCH 14/14] [SEVERE] Resolve disposal exceptions when threading * Was not setting the disposal flag early enough so resources were still being used after they were disposed of. * Removed disposal exception for _queue.Execute() due to the rate at which it is fired. --- .../CatalystUI.Threading/DelegateQueue.cs | 9 +++----- .../ThreadDelegateDispatcher.cs | 21 +++++++------------ 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs index e4bc5e7..4dd3ef2 100644 --- a/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs +++ b/CatalystUI/Core/CatalystUI.Threading/DelegateQueue.cs @@ -206,10 +206,9 @@ public bool Enqueue(Delegate @delegate, nint caller, nint parameters, out nint @ /// Loops through the queue and executes all enqueued delegates. /// public void Execute() { - ObjectDisposedException.ThrowIf(_disposed, this); - // Wait until there's at least one delegate to execute _delegateEnqueuedWaitHandle.WaitOne(); + if (_disposed) return; // if we've disposed of ourselves while waiting, exit immediately _delegateEnqueuedWaitHandle.Reset(); // Enter the lock to begin execution @@ -294,12 +293,12 @@ private void Dispose(bool disposing) { _lock.Enter(); try { if (_disposed) return; + _disposed = true; // Dispose managed state (managed objects) if (disposing) { // Dispose of the delegate queue wait handle _delegateEnqueuedWaitHandle.Set(); - _delegateEnqueuedWaitHandle.Dispose(); // Dispose of the underlying wait handles EnqueuedDelegate[] items = _queue.Items; @@ -311,13 +310,11 @@ private void Dispose(bool disposing) { // Dispose the queue full wait handle _queueFullWaitHandle.Dispose(); + _delegateEnqueuedWaitHandle.Dispose(); } // Dispose unmanaged state (unmanaged objects) // ... - - // Indicate disposal completion - _disposed = true; } finally { _lock.Exit(); } diff --git a/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs b/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs index 6d7fb4d..8b6de97 100644 --- a/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs +++ b/CatalystUI/Core/CatalystUI.Threading/ThreadDelegateDispatcher.cs @@ -609,17 +609,12 @@ private void OnDelegateFinished(Delegate @delegate) { /// private void WorkerThread() { while (true) { - _lock.Enter(); - try { - if (_disposed) break; - OnPreExecute(); - if (_disposed) break; - _queue.Execute(); - if (_disposed) break; - OnPostExecute(); - } finally { - _lock.Exit(); - } + if (_disposed) break; + OnPreExecute(); + if (_disposed) break; + _queue.Execute(); + if (_disposed) break; + OnPostExecute(); } } @@ -638,6 +633,7 @@ private void Dispose(bool disposing) { _lock.Enter(); try { if (_disposed) return; + _disposed = true; // Dispose managed state (managed objects) if (disposing) { @@ -651,9 +647,6 @@ private void Dispose(bool disposing) { // Dispose unmanaged state (unmanaged objects) // ... - - // Indicate disposal completion - _disposed = true; } finally { _lock.Exit(); }