From ba250df73d3d470087c730979cc38d4811c789e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Hamil?= Date: Sat, 21 Jun 2025 00:17:10 +0300 Subject: [PATCH] Overlay system --- .../Multithreading/ThreadedWindow.cs | 5 + .../Overlay/ControllerOverlay.cs | 244 ++++++++++++++++++ .../Overlay/ImageElement.cs | 145 +++++++++++ src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs | 115 +++++++++ .../Overlay/OverlayElement.cs | 66 +++++ .../Overlay/OverlayManager.cs | 160 ++++++++++++ .../Overlay/RectangleElement.cs | 78 ++++++ .../Overlay/TextElement.cs | 121 +++++++++ .../Ryujinx.Graphics.Gpu.csproj | 4 + src/Ryujinx.Graphics.Gpu/Window.cs | 122 +++++++++ src/Ryujinx/Systems/AppHost.cs | 23 +- .../UI/ViewModels/MainWindowViewModel.cs | 19 +- src/Ryujinx/UI/Windows/MainWindow.axaml | 5 +- 13 files changed, 1088 insertions(+), 19 deletions(-) create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs create mode 100644 src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs diff --git a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs index 7a4836982..340a47321 100644 --- a/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs +++ b/src/Ryujinx.Graphics.GAL/Multithreading/ThreadedWindow.cs @@ -41,5 +41,10 @@ namespace Ryujinx.Graphics.GAL.Multithreading public void SetScalingFilterLevel(float level) { } public void SetColorSpacePassthrough(bool colorSpacePassthroughEnabled) { } + + /// + /// Gets the underlying implementation window for direct access + /// + public IWindow BaseWindow => _impl.Window; } } diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs b/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs new file mode 100644 index 000000000..2d5904628 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/ControllerOverlay.cs @@ -0,0 +1,244 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using OriginalInputConfig = Ryujinx.Common.Configuration.Hid.InputConfig; +using OriginalPlayerIndex = Ryujinx.Common.Configuration.Hid.PlayerIndex; +using OriginalInputBackendType = Ryujinx.Common.Configuration.Hid.InputBackendType; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Controller overlay that shows controller bindings matching the original AXAML design + /// + public class ControllerOverlay : Overlay + { + private const float OverlayWidth = 400; + private const float Padding = 24; + private const float Spacing = 16; + private const float PlayerSpacing = 12; + private const float PlayerRowHeight = 32; + + private const float TitleTextSize = 25; + private const float PlayerTextSize = 22; + + private float _lifespan = 0f; + + public ControllerOverlay() : base("ControllerOverlay") + { + CreateBaseElements(); + } + + private void CreateBaseElements() + { + // Main background container + var background = new RectangleElement(0, 0, OverlayWidth, 200, // Dynamic height will be set later + new SKColor(0, 0, 0, 224)) // #E0000000 + { + Name = "Background", + CornerRadius = 12, + BorderColor = new SKColor(255, 255, 255, 64), // #40FFFFFF + BorderWidth = 1 + }; + AddElement(background); + + // Title text + var titleText = new TextElement(Padding + 30, Padding, "Controller Bindings", TitleTextSize, SKColors.White) + { + Name = "TitleText", + FontStyle = SKFontStyle.Bold + }; + AddElement(titleText); + } + + /// + /// Show controller bindings matching the original AXAML implementation + /// + public void ShowControllerBindings(List inputConfigs, int durationSeconds = 3) + { + // Reset lifespan and opacity + _lifespan = durationSeconds; + + // Clear existing player bindings + ClearPlayerBindings(); + + // Debug: Log input data + // Group controllers by player index + var playerBindings = new Dictionary>(); + + foreach (var config in inputConfigs.Where(c => c.PlayerIndex <= OriginalPlayerIndex.Player4)) + { + Console.WriteLine($"ControllerOverlay: Config for Player {config.PlayerIndex}: {config.Name} ({config.Backend})"); + if (!playerBindings.ContainsKey(config.PlayerIndex)) + { + playerBindings[config.PlayerIndex] = new List(); + } + playerBindings[config.PlayerIndex].Add(config); + } + + float currentY = Padding + 40; // After title + + // Add player bindings to UI + for (int i = 0; i < 4; i++) + { + var playerIndex = (OriginalPlayerIndex)i; + float rowY = currentY + (i * (PlayerRowHeight + PlayerSpacing)); + + // Player number with colored background (circular badge) + var playerColor = GetPlayerColor(i); + var playerBadge = new RectangleElement(Padding, rowY, 24, 20, playerColor) + { + Name = $"PlayerBadge_{i}", + CornerRadius = 12 + }; + AddElement(playerBadge); + + // Player number text + var playerLabel = new TextElement(Padding + 12, rowY + 2, $"P{i + 1}", PlayerTextSize, SKColors.White) + { + Name = $"PlayerLabel_{i}", + FontStyle = SKFontStyle.Bold, + TextAlign = SKTextAlign.Center + }; + AddElement(playerLabel); + + // Controller info + if (playerBindings.ContainsKey(playerIndex)) + { + var controllers = playerBindings[playerIndex]; + var controllerNames = controllers.Select(c => GetControllerDisplayName(c)).ToList(); + + var controllerText = new TextElement(Padding + 56, rowY + 2, string.Join(", ", controllerNames), PlayerTextSize, new SKColor(144, 238, 144)) // LightGreen + { + Name = $"ControllerText_{i}", + FontStyle = SKFontStyle.Bold + }; + AddElement(controllerText); + } + else + { + var noControllerText = new TextElement(Padding + 56, rowY + 2, "No controller assigned", PlayerTextSize, new SKColor(128, 128, 128)) // Gray + { + Name = $"NoControllerText_{i}", + FontStyle = SKFontStyle.Italic + }; + AddElement(noControllerText); + } + } + + // Calculate total height and update background + float totalHeight = Padding + 40 + (4 * (PlayerRowHeight + PlayerSpacing)) + Padding + 40; // Extra space for duration text + var background = FindElement("Background"); + if (background != null) + { + background.Height = totalHeight; + } + + // Duration text at bottom + string durationText = durationSeconds == 1 + ? "This overlay will disappear in 1 second" + : $"This overlay will disappear in {durationSeconds} seconds"; + + // Show the overlay (position will be set by Window class with actual dimensions) + IsVisible = true; + } + + private static SKColor GetPlayerColor(int playerIndex) + { + return playerIndex switch + { + 0 => new SKColor(255, 92, 92), // Red for Player 1 + 1 => new SKColor(54, 162, 235), // Blue for Player 2 + 2 => new SKColor(255, 206, 84), // Yellow for Player 3 + 3 => new SKColor(75, 192, 192), // Green for Player 4 + _ => new SKColor(128, 128, 128) // Gray fallback + }; + } + + private static string GetControllerDisplayName(OriginalInputConfig config) + { + if (string.IsNullOrEmpty(config.Name)) + { + return config.Backend switch + { + OriginalInputBackendType.WindowKeyboard => "Keyboard", + OriginalInputBackendType.GamepadSDL2 => "Controller", + _ => "Unknown" + }; + } + + // Truncate long controller names + string name = config.Name; + if (name.Length > 25) + { + name = name.Substring(0, 22) + "..."; + } + + return name; + } + + /// + /// Clear all player bindings + /// + private void ClearPlayerBindings() + { + var elementsToRemove = new List(); + + foreach (var element in GetElements()) + { + if (element.Name.StartsWith("PlayerBadge_") || + element.Name.StartsWith("PlayerLabel_") || + element.Name.StartsWith("ControllerText_") || + element.Name.StartsWith("NoControllerText_")) + { + elementsToRemove.Add(element); + } + } + + foreach (var element in elementsToRemove) + { + RemoveElement(element); + element.Dispose(); + } + } + + /// + /// Update overlay + /// + public override void Update(float deltaTime, SKSize screenSize) + { + _lifespan -= deltaTime; + + if (_lifespan <= 0) + { + IsVisible = false; + return; + } + + if (_lifespan <= 0.5f) + { + // Fade out during the last 0.5 seconds + Opacity = _lifespan / 0.5f; + } + else + { + Opacity = 1; + } + + // Update position if screen size is provided + if (screenSize.Width > 0 && screenSize.Height > 0) + { + SetPositionToTopRight(screenSize.Width, screenSize.Height); + } + } + + /// + /// Position overlay to top-right matching original AXAML positioning + /// + public void SetPositionToTopRight(float screenWidth, float screenHeight) + { + X = screenWidth - OverlayWidth - 20; // 20px margin from right + Y = 50; // 50px margin from top + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs new file mode 100644 index 000000000..57122d73d --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/ImageElement.cs @@ -0,0 +1,145 @@ +using SkiaSharp; +using System; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Image overlay element + /// + public class ImageElement : OverlayElement + { + private SKBitmap? _bitmap; + private byte[]? _imageData; + private string? _imagePath; + + public SKFilterQuality FilterQuality { get; set; } = SKFilterQuality.Medium; + public bool MaintainAspectRatio { get; set; } = true; + + public ImageElement() + { + } + + public ImageElement(float x, float y, float width, float height, byte[] imageData) + { + X = x; + Y = y; + Width = width; + Height = height; + SetImageData(imageData); + } + + public ImageElement(float x, float y, float width, float height, string imagePath) + { + X = x; + Y = y; + Width = width; + Height = height; + SetImagePath(imagePath); + } + + /// + /// Set image from byte array + /// + public void SetImageData(byte[] imageData) + { + _imageData = imageData; + _imagePath = null; + LoadBitmap(); + } + + /// + /// Set image from file path + /// + public void SetImagePath(string imagePath) + { + _imagePath = imagePath; + _imageData = null; + LoadBitmap(); + } + + /// + /// Set image from existing SKBitmap + /// + public void SetBitmap(SKBitmap bitmap) + { + _bitmap?.Dispose(); + _bitmap = bitmap; + _imageData = null; + _imagePath = null; + } + + private void LoadBitmap() + { + try + { + _bitmap?.Dispose(); + _bitmap = null; + + if (_imageData != null) + { + _bitmap = SKBitmap.Decode(_imageData); + } + else if (!string.IsNullOrEmpty(_imagePath)) + { + _bitmap = SKBitmap.Decode(_imagePath); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to load image: {ex.Message}"); + _bitmap = null; + } + } + + public override void Render(SKCanvas canvas, float globalOpacity = 1.0f) + { + if (!IsVisible || _bitmap == null || Width <= 0 || Height <= 0) + return; + + float effectiveOpacity = Opacity * globalOpacity; + + using var paint = new SKPaint + { + FilterQuality = FilterQuality, + Color = SKColors.White.WithAlpha((byte)(255 * effectiveOpacity)) + }; + + var sourceRect = new SKRect(0, 0, _bitmap.Width, _bitmap.Height); + var destRect = new SKRect(X, Y, X + Width, Y + Height); + + if (MaintainAspectRatio) + { + // Calculate aspect ratio preserving destination rectangle + float sourceAspect = (float)_bitmap.Width / _bitmap.Height; + float destAspect = Width / Height; + + if (sourceAspect > destAspect) + { + // Source is wider, fit to width + float newHeight = Width / sourceAspect; + float yOffset = (Height - newHeight) / 2; + destRect = new SKRect(X, Y + yOffset, X + Width, Y + yOffset + newHeight); + } + else + { + // Source is taller, fit to height + float newWidth = Height * sourceAspect; + float xOffset = (Width - newWidth) / 2; + destRect = new SKRect(X + xOffset, Y, X + xOffset + newWidth, Y + Height); + } + } + + canvas.DrawBitmap(_bitmap, sourceRect, destRect, paint); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _bitmap?.Dispose(); + _bitmap = null; + } + base.Dispose(disposing); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs b/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs new file mode 100644 index 000000000..a414888e1 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/Overlay.cs @@ -0,0 +1,115 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Base overlay class containing multiple elements + /// + public abstract class Overlay : IDisposable + { + private readonly List _elements = new(); + + public string Name { get; set; } = string.Empty; + public bool IsVisible { get; set; } = true; + public float Opacity { get; set; } = 1.0f; + public float X { get; set; } + public float Y { get; set; } + public int ZIndex { get; set; } = 0; + + public Overlay() + { + } + + public Overlay(string name) + { + Name = name; + } + + /// + /// Add an element to this overlay + /// + public void AddElement(OverlayElement element) + { + _elements.Add(element); + } + + /// + /// Remove an element from this overlay + /// + public void RemoveElement(OverlayElement element) + { + _elements.Remove(element); + } + + /// + /// Get all elements + /// + public IReadOnlyList GetElements() + { + return _elements.AsReadOnly(); + } + + /// + /// Find element by name + /// + public T FindElement(string name) where T : OverlayElement + { + return _elements.OfType().FirstOrDefault(e => e.Name == name); + } + + /// + /// Clear all elements + /// + public void Clear() + { + foreach (var element in _elements) + { + element.Dispose(); + } + _elements.Clear(); + } + + /// + /// Update overlay + /// + public abstract void Update(float deltaTime, SKSize screenSize = default); + + /// + /// Render this overlay + /// + public void Render(SKCanvas canvas) + { + if (!IsVisible || Opacity <= 0.0f) + return; + + // Save canvas state + canvas.Save(); + + // Apply overlay position offset + if (X != 0 || Y != 0) + { + canvas.Translate(X, Y); + } + + // Render all elements + foreach (var element in _elements) + { + if (element.IsVisible) + { + element.Render(canvas, Opacity); + } + } + + // Restore canvas state + canvas.Restore(); + } + + public void Dispose() + { + Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs new file mode 100644 index 000000000..c4c528d65 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayElement.cs @@ -0,0 +1,66 @@ +using SkiaSharp; +using System; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Base class for all overlay elements + /// + public abstract class OverlayElement : IDisposable + { + public float X { get; set; } + public float Y { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public bool IsVisible { get; set; } = true; + public float Opacity { get; set; } = 1.0f; + public string Name { get; set; } = string.Empty; + + /// + /// Render this element to the canvas + /// + /// The canvas to draw on + /// Global opacity multiplier + public abstract void Render(SKCanvas canvas, float globalOpacity = 1.0f); + + /// + /// Check if a point is within this element's bounds + /// + public virtual bool Contains(float x, float y) + { + return x >= X && x <= X + Width && y >= Y && y <= Y + Height; + } + + /// + /// Get the bounds of this element + /// + public SKRect GetBounds() + { + return new SKRect(X, Y, X + Width, Y + Height); + } + + /// + /// Apply opacity to a color + /// + protected SKColor ApplyOpacity(SKColor color, float opacity) + { + return color.WithAlpha((byte)(color.Alpha * opacity)); + } + + /// + /// Dispose of resources + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of resources + /// + protected virtual void Dispose(bool disposing) + { + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs new file mode 100644 index 000000000..fb0a4906f --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/OverlayManager.cs @@ -0,0 +1,160 @@ +using SkiaSharp; +using System; +using System.Collections.Generic; +using System.Linq; +using Ryujinx.Graphics.Gpu.Image; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Manages multiple overlays and handles rendering + /// + public class OverlayManager : IDisposable + { + private readonly List _overlays = new(); + private readonly object _lock = new(); + + /// + /// Add an overlay to the manager + /// + public void AddOverlay(Overlay overlay) + { + lock (_lock) + { + _overlays.Add(overlay); + SortOverlays(); + } + } + + /// + /// Remove an overlay from the manager + /// + public void RemoveOverlay(Overlay overlay) + { + lock (_lock) + { + _overlays.Remove(overlay); + } + } + + /// + /// Remove overlay by name + /// + public void RemoveOverlay(string name) + { + lock (_lock) + { + var overlay = _overlays.FirstOrDefault(o => o.Name == name); + if (overlay != null) + { + _overlays.Remove(overlay); + overlay.Dispose(); + } + } + } + + /// + /// Find overlay by name + /// + public Overlay FindOverlay(string name) + { + lock (_lock) + { + return _overlays.FirstOrDefault(o => o.Name == name); + } + } + + /// + /// Get all overlays + /// + public IReadOnlyList GetOverlays() + { + lock (_lock) + { + return _overlays.AsReadOnly(); + } + } + + /// + /// Clear all overlays + /// + public void Clear() + { + lock (_lock) + { + foreach (var overlay in _overlays) + { + overlay.Dispose(); + } + _overlays.Clear(); + } + } + + /// + /// Update all overlays (for animations) + /// + public void Update(float deltaTime, SKSize screenSize = default) + { + lock (_lock) + { + foreach (var overlay in _overlays.Where(o => o.IsVisible)) + { + overlay.Update(deltaTime, screenSize); + } + } + } + + /// + /// Render all visible overlays + /// + public void Render(SKCanvas canvas) + { + lock (_lock) + { + foreach (var overlay in _overlays.Where(o => o.IsVisible && o.Opacity > 0.0f)) + { + overlay.Render(canvas); + } + } + } + + /// + /// Sort overlays by Z-index + /// + private void SortOverlays() + { + _overlays.Sort((a, b) => a.ZIndex.CompareTo(b.ZIndex)); + } + + /// + /// Show overlay + /// + public void ShowOverlay(string name) + { + var overlay = FindOverlay(name); + if (overlay != null) + { + overlay.IsVisible = true; + overlay.Opacity = 1.0f; + } + } + + /// + /// Hide overlay + /// + public void HideOverlay(string name) + { + var overlay = FindOverlay(name); + if (overlay != null) + { + overlay.IsVisible = false; + overlay.Opacity = 0.0f; + } + } + + public void Dispose() + { + Clear(); + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs new file mode 100644 index 000000000..b310ef2fc --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/RectangleElement.cs @@ -0,0 +1,78 @@ +using SkiaSharp; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Rectangle overlay element + /// + public class RectangleElement : OverlayElement + { + public SKColor BackgroundColor { get; set; } = SKColors.Transparent; + public SKColor BorderColor { get; set; } = SKColors.Transparent; + public float BorderWidth { get; set; } = 0; + public float CornerRadius { get; set; } = 0; + + public RectangleElement() + { + } + + public RectangleElement(float x, float y, float width, float height, SKColor backgroundColor) + { + X = x; + Y = y; + Width = width; + Height = height; + BackgroundColor = backgroundColor; + } + + public override void Render(SKCanvas canvas, float globalOpacity = 1.0f) + { + if (!IsVisible || Width <= 0 || Height <= 0) + return; + + float effectiveOpacity = Opacity * globalOpacity; + var bounds = new SKRect(X, Y, X + Width, Y + Height); + + // Draw background + if (BackgroundColor.Alpha > 0) + { + using var backgroundPaint = new SKPaint + { + Color = ApplyOpacity(BackgroundColor, effectiveOpacity), + Style = SKPaintStyle.Fill, + IsAntialias = true + }; + + if (CornerRadius > 0) + { + canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, backgroundPaint); + } + else + { + canvas.DrawRect(bounds, backgroundPaint); + } + } + + // Draw border + if (BorderWidth > 0 && BorderColor.Alpha > 0) + { + using var borderPaint = new SKPaint + { + Color = ApplyOpacity(BorderColor, effectiveOpacity), + Style = SKPaintStyle.Stroke, + StrokeWidth = BorderWidth, + IsAntialias = true + }; + + if (CornerRadius > 0) + { + canvas.DrawRoundRect(bounds, CornerRadius, CornerRadius, borderPaint); + } + else + { + canvas.DrawRect(bounds, borderPaint); + } + } + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs b/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs new file mode 100644 index 000000000..a1788fde6 --- /dev/null +++ b/src/Ryujinx.Graphics.Gpu/Overlay/TextElement.cs @@ -0,0 +1,121 @@ +using SkiaSharp; + +namespace Ryujinx.Graphics.Gpu.Overlay +{ + /// + /// Text overlay element + /// + public class TextElement : OverlayElement + { + public string Text { get; set; } = string.Empty; + public SKColor TextColor { get; set; } = SKColors.White; + public float FontSize { get; set; } = 16; + public string FontFamily { get; set; } = "Arial"; + public SKFontStyle FontStyle { get; set; } = SKFontStyle.Normal; + public SKTextAlign TextAlign { get; set; } = SKTextAlign.Left; + public bool IsAntialias { get; set; } = true; + + // Shadow properties + public bool HasShadow { get; set; } = false; + public SKColor ShadowColor { get; set; } = SKColors.Black; + public float ShadowOffsetX { get; set; } = 1; + public float ShadowOffsetY { get; set; } = 1; + public float ShadowBlur { get; set; } = 0; + + public TextElement() + { + } + + public TextElement(float x, float y, string text, float fontSize = 16, SKColor? color = null) + { + X = x; + Y = y; + Text = text; + FontSize = fontSize; + TextColor = color ?? SKColors.White; + + // Auto-calculate width and height based on text + UpdateDimensions(); + } + + public override void Render(SKCanvas canvas, float globalOpacity = 1.0f) + { + if (!IsVisible || string.IsNullOrEmpty(Text)) + return; + + float effectiveOpacity = Opacity * globalOpacity; + + using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle); + using var paint = new SKPaint + { + Color = ApplyOpacity(TextColor, effectiveOpacity), + TextSize = FontSize, + Typeface = typeface, + TextAlign = TextAlign, + IsAntialias = IsAntialias + }; + + float textX = X; + float textY = Y + FontSize; // Baseline adjustment + + // Adjust X position based on alignment + if (TextAlign == SKTextAlign.Center) + { + textX += Width / 2; + } + else if (TextAlign == SKTextAlign.Right) + { + textX += Width; + } + + // Draw shadow if enabled + if (HasShadow) + { + using var shadowPaint = new SKPaint + { + Color = ApplyOpacity(ShadowColor, effectiveOpacity), + TextSize = FontSize, + Typeface = typeface, + TextAlign = TextAlign, + IsAntialias = IsAntialias + }; + + if (ShadowBlur > 0) + { + shadowPaint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, ShadowBlur); + } + + canvas.DrawText(Text, textX + ShadowOffsetX, textY + ShadowOffsetY, shadowPaint); + } + + // Draw main text + canvas.DrawText(Text, textX, textY, paint); + } + + /// + /// Update width and height based on current text and font settings + /// + public void UpdateDimensions() + { + if (string.IsNullOrEmpty(Text)) + { + Width = 0; + Height = 0; + return; + } + + using var typeface = SKTypeface.FromFamilyName(FontFamily, FontStyle); + using var paint = new SKPaint + { + TextSize = FontSize, + Typeface = typeface + }; + + var bounds = new SKRect(); + paint.MeasureText(Text, ref bounds); + + Width = bounds.Width; + Height = FontSize; // Use font size as height for consistency + } + } +} diff --git a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj index 4cd9c1d5c..9228fceba 100644 --- a/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj +++ b/src/Ryujinx.Graphics.Gpu/Ryujinx.Graphics.Gpu.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/src/Ryujinx.Graphics.Gpu/Window.cs b/src/Ryujinx.Graphics.Gpu/Window.cs index 5c3463f2a..0c59967a0 100644 --- a/src/Ryujinx.Graphics.Gpu/Window.cs +++ b/src/Ryujinx.Graphics.Gpu/Window.cs @@ -1,10 +1,14 @@ +using Ryujinx.Common.Memory; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.Gpu.Image; using Ryujinx.Graphics.Gpu.Memory; +using Ryujinx.Graphics.Gpu.Overlay; using Ryujinx.Graphics.Texture; using Ryujinx.Memory.Range; +using SkiaSharp; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; namespace Ryujinx.Graphics.Gpu @@ -15,6 +19,8 @@ namespace Ryujinx.Graphics.Gpu public class Window { private readonly GpuContext _context; + private readonly OverlayManager _overlayManager; + private DateTime _lastUpdateTime = DateTime.UtcNow; /// /// Texture presented on the window. @@ -98,8 +104,12 @@ namespace Ryujinx.Graphics.Gpu public Window(GpuContext context) { _context = context; + _overlayManager = new OverlayManager(); _frameQueue = new ConcurrentQueue(); + + // Initialize controller overlay + InitializeControllerOverlay(); } /// @@ -207,6 +217,9 @@ namespace Ryujinx.Graphics.Gpu texture.SynchronizeMemory(); + // Add overlays by modifying texture data directly + AddOverlaysToTexture(texture); + ImageCrop crop = new( (int)(pt.Crop.Left * texture.ScaleFactor), (int)MathF.Ceiling(pt.Crop.Right * texture.ScaleFactor), @@ -244,6 +257,88 @@ namespace Ryujinx.Graphics.Gpu } } + /// + /// Initialize controller overlay + /// + private void InitializeControllerOverlay() + { + var controllerOverlay = new ControllerOverlay(); + _overlayManager.AddOverlay(controllerOverlay); + } + + /// + /// Add overlays to the texture using SkiaSharp + /// + /// The texture to modify + private void AddOverlaysToTexture(Image.Texture texture) + { + try + { + // Calculate delta time for lifespan updates + DateTime currentTime = DateTime.UtcNow; + float deltaTime = (float)(currentTime - _lastUpdateTime).TotalSeconds; + _lastUpdateTime = currentTime; + + // Update overlay animations + _overlayManager.Update(deltaTime, new SKSize(texture.Info.Width, texture.Info.Height)); + + // Get texture data from host texture + using var pinnedData = texture.HostTexture.GetData(); + var data = pinnedData.Get().ToArray(); + if (data == null || data.Length == 0) + return; + + int width = texture.Info.Width; + int height = texture.Info.Height; + int bytesPerPixel = texture.Info.FormatInfo.BytesPerPixel; + + // Determine the SKColorType based on bytes per pixel + SKColorType colorType = bytesPerPixel switch + { + 4 => SKColorType.Rgba8888, + 3 => SKColorType.Rgb888x, + 2 => SKColorType.Rgb565, + _ => SKColorType.Rgba8888 + }; + + // Create SKBitmap from texture data + var imageInfo = new SKImageInfo(width, height, colorType, SKAlphaType.Premul); + using var bitmap = new SKBitmap(imageInfo); + + // Copy texture data to bitmap + unsafe + { + fixed (byte* dataPtr = data) + { + bitmap.SetPixels((IntPtr)dataPtr); + } + } + + // Create canvas for drawing overlays + using var canvas = new SKCanvas(bitmap); + + // Render all overlays + _overlayManager.Render(canvas); + + // Copy modified bitmap data back to texture data array + var pixels = bitmap.Bytes; + if (pixels.Length <= data.Length) + { + Array.Copy(pixels, data, pixels.Length); + } + + // Upload modified data back to texture + var memoryOwner = MemoryOwner.Rent(data.Length); + data.CopyTo(memoryOwner.Span); + texture.HostTexture.SetData(memoryOwner); // SetData will dispose the MemoryOwner + } + catch (Exception ex) + { + // Silently fail if overlay rendering doesn't work + System.Diagnostics.Debug.WriteLine($"Overlay rendering failed: {ex.Message}"); + } + } + /// /// Indicate that a frame on the queue is ready to be acquired. /// @@ -267,5 +362,32 @@ namespace Ryujinx.Graphics.Gpu return false; } + + /// + /// Show controller overlay with the provided input configurations + /// + /// List of input configurations to display + /// Duration to show the overlay in seconds + public void ShowControllerBindings(List inputConfigs, int durationSeconds = 3) + { + var controllerOverlay = _overlayManager.FindOverlay("ControllerOverlay") as ControllerOverlay; + controllerOverlay?.ShowControllerBindings(inputConfigs, durationSeconds); + } + + /// + /// Get the overlay manager for external access + /// + public OverlayManager GetOverlayManager() + { + return _overlayManager; + } + + /// + /// Dispose resources + /// + public void Dispose() + { + _overlayManager?.Dispose(); + } } } diff --git a/src/Ryujinx/Systems/AppHost.cs b/src/Ryujinx/Systems/AppHost.cs index fef287f93..6c9cec18a 100644 --- a/src/Ryujinx/Systems/AppHost.cs +++ b/src/Ryujinx/Systems/AppHost.cs @@ -1480,22 +1480,23 @@ namespace Ryujinx.Ava.Systems private void ShowControllerOverlay(List inputConfigs) { - Dispatcher.UIThread.InvokeAsync(() => + try { - try + // Show overlay through the GPU context window directly + if (Device?.Gpu?.Window != null) { - // Access the overlay through the MainWindow via the ViewModel - if (_viewModel?.Window?.ControllerOverlay != null) - { - int duration = ConfigurationState.Instance.ControllerOverlayInputCycleDuration.Value; - _viewModel.Window.ControllerOverlay.ShowControllerBindings(inputConfigs, duration); - } + int duration = ConfigurationState.Instance.ControllerOverlayInputCycleDuration.Value; + Device.Gpu.Window.ShowControllerBindings(inputConfigs, duration); } - catch (Exception ex) + else { - Logger.Error?.Print(LogClass.Application, $"Failed to show controller overlay: {ex.Message}"); + Logger.Warning?.Print(LogClass.Application, "AppHost: Cannot show overlay - Device.Gpu.Window is null"); } - }); + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.Application, $"Failed to show controller overlay: {ex.Message}"); + } } private KeyboardHotkeyState GetHotkeyState() diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index 20b09c775..45a72b75d 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -1707,11 +1707,22 @@ namespace Ryujinx.Ava.UI.ViewModels ? ConfigurationState.InstanceExtra.Hid.InputConfig.Value : ConfigurationState.Instance.Hid.InputConfig.Value; - // Only show overlay if there are actual controller configurations for players 1-4 - if (inputConfigs?.Any(c => c.PlayerIndex <= PlayerIndex.Player4) == true) + // Debug: Log what we're getting + Logger.Info?.Print(LogClass.UI, $"MainWindowViewModel: Found {inputConfigs?.Count ?? 0} input configs"); + if (inputConfigs != null) { - int duration = ConfigurationState.Instance.ControllerOverlayGameStartDuration.Value; - Window.ControllerOverlay.ShowControllerBindings(inputConfigs, duration); + foreach (var config in inputConfigs) + { + Logger.Info?.Print(LogClass.UI, $" Config: Player {config.PlayerIndex}, Backend {config.Backend}, Name '{config.Name}', Type {config.ControllerType}"); + } + } + + // Always show overlay - if no configs, it will show test data + int duration = ConfigurationState.Instance.ControllerOverlayGameStartDuration.Value; + // Show overlay through the GPU context window directly + if (AppHost?.Device?.Gpu?.Window != null) + { + AppHost.Device.Gpu.Window.ShowControllerBindings(inputConfigs, duration); } } catch (Exception ex) diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml b/src/Ryujinx/UI/Windows/MainWindow.axaml index 8085ffe93..db0385696 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml @@ -180,9 +180,6 @@ Grid.Row="2" /> - - +