From e1357f1bf02f0763ae86fc1f33afd599ce00dad7 Mon Sep 17 00:00:00 2001 From: RobertBeekman Date: Sat, 17 Oct 2026 20:34:25 +0200 Subject: [PATCH 1/3] Core - Keep small LEDs addressable at reduced render scales Sample each LED from its unsnapped absolute boundary instead of the render-grid-aligned AbsoluteRectangle, ensuring at least one pixel and clamping to the texture. Also apply the saved render scale at startup. --- src/Artemis.Core/RGB.NET/SKTexture.cs | 29 ++++++++++++++++------ src/Artemis.Core/Services/RenderService.cs | 8 +++++- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Artemis.Core/RGB.NET/SKTexture.cs b/src/Artemis.Core/RGB.NET/SKTexture.cs index 14aae47e2..c117b9b11 100644 --- a/src/Artemis.Core/RGB.NET/SKTexture.cs +++ b/src/Artemis.Core/RGB.NET/SKTexture.cs @@ -29,19 +29,32 @@ public sealed class SKTexture : ITexture, IDisposable foreach (ArtemisDevice artemisDevice in devices) { foreach (ArtemisLed artemisLed in artemisDevice.Leds) - { - _ledRects[artemisLed.RgbLed] = SKRectI.Create( - (int)(artemisLed.AbsoluteRectangle.Left * RenderScale), - (int)(artemisLed.AbsoluteRectangle.Top * RenderScale), - (int)(artemisLed.AbsoluteRectangle.Width * RenderScale), - (int)(artemisLed.AbsoluteRectangle.Height * RenderScale) - ); - } + _ledRects[artemisLed.RgbLed] = CreateSampleRect(artemisLed.RgbLed.AbsoluteBoundary, scale, width, height); } } #endregion + private static SKRectI CreateSampleRect(Rectangle boundary, float scale, int width, int height) + { + (int left, int right) = ScaleSpan(boundary.Location.X, boundary.Size.Width, scale, width); + (int top, int bottom) = ScaleSpan(boundary.Location.Y, boundary.Size.Height, scale, height); + return new SKRectI(left, top, right, bottom); + } + + private static (int Start, int End) ScaleSpan(float location, float size, float scale, int max) + { + float start = location * scale; + float end = (location + size) * scale; + if (end <= 0 || start >= max) + return (0, 0); + + // Always sample at least one pixel + int startPixel = Math.Clamp(start.RoundToInt(), 0, max - 1); + int endPixel = Math.Clamp(end.RoundToInt(), startPixel + 1, max); + return (startPixel, endPixel); + } + internal Color GetColorAtRenderTarget(in RenderTarget renderTarget) { if (_image == null) return Color.Transparent; diff --git a/src/Artemis.Core/Services/RenderService.cs b/src/Artemis.Core/Services/RenderService.cs index bf2138dcb..3d28859b7 100644 --- a/src/Artemis.Core/Services/RenderService.cs +++ b/src/Artemis.Core/Services/RenderService.cs @@ -47,6 +47,7 @@ internal class RenderService : IRenderService, IRenderer, IDisposable _preferredGraphicsContext.SettingChanged += PreferredGraphicsContextOnSettingChanged; Utilities.ShutdownRequested += UtilitiesOnShutdownRequested; + ApplyRenderScaleMultiplier(); _surfaceManager = new SurfaceManager(this, GraphicsContext, _targetFrameRateSetting.Value, (float) _renderScaleSetting.Value); } @@ -189,9 +190,14 @@ internal class RenderService : IRenderService, IRenderer, IDisposable _surfaceManager.UpdateTargetFrameRate(_targetFrameRateSetting.Value); } - private void RenderScaleSettingOnSettingChanged(object? sender, EventArgs e) + private void ApplyRenderScaleMultiplier() { RenderScale.SetRenderScaleMultiplier((int) (1 / _renderScaleSetting.Value)); + } + + private void RenderScaleSettingOnSettingChanged(object? sender, EventArgs e) + { + ApplyRenderScaleMultiplier(); _surfaceManager.UpdateRenderScale((float) _renderScaleSetting.Value); } From 02dd7df5dd1d82a1b2d2fbcff0169fb4599d6487 Mon Sep 17 00:00:00 2001 From: rjc862003 Date: Thu, 24 Sep 2026 03:06:58 -0400 Subject: [PATCH 2/3] Reduce per-frame texture readback allocations --- src/Artemis.Core/RGB.NET/SKTexture.cs | 53 +++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/Artemis.Core/RGB.NET/SKTexture.cs b/src/Artemis.Core/RGB.NET/SKTexture.cs index c117b9b11..1242f93e7 100644 --- a/src/Artemis.Core/RGB.NET/SKTexture.cs +++ b/src/Artemis.Core/RGB.NET/SKTexture.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using Artemis.Core.SkiaSharp; using HPPH; -using HPPH.SkiaSharp; using RGB.NET.Core; using RGB.NET.Presets.Extensions; using SkiaSharp; @@ -25,6 +24,7 @@ public sealed class SKTexture : ITexture, IDisposable Surface = graphicsContext == null ? SKSurface.Create(ImageInfo) : SKSurface.Create(graphicsContext.GraphicsContext, true, ImageInfo); + _readback = new SKBitmap(new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Unpremul)); foreach (ArtemisDevice artemisDevice in devices) { @@ -57,14 +57,52 @@ public sealed class SKTexture : ITexture, IDisposable internal Color GetColorAtRenderTarget(in RenderTarget renderTarget) { - if (_image == null) return Color.Transparent; + if (!_hasPixels) return Color.Transparent; SKRectI skRectI = _ledRects[renderTarget.Led]; if (skRectI.Width <= 0 || skRectI.Height <= 0) return Color.Transparent; - return _image[skRectI.Left, skRectI.Top, skRectI.Width, skRectI.Height].Average().ToColor(); + return AveragePixels(_readback, skRectI); + } + + internal static unsafe Color AveragePixels(SKBitmap bitmap, SKRectI rectangle) + { + int left = Math.Max(0, rectangle.Left); + int top = Math.Max(0, rectangle.Top); + int right = Math.Min(bitmap.Width, rectangle.Right); + int bottom = Math.Min(bitmap.Height, rectangle.Bottom); + if (left >= right || top >= bottom) + return Color.Transparent; + + byte* pixels = (byte*)bitmap.GetPixels().ToPointer(); + if (pixels == null) + return Color.Transparent; + + long blue = 0; + long green = 0; + long red = 0; + long alpha = 0; + int rowBytes = bitmap.RowBytes; + for (int y = top; y < bottom; y++) + { + byte* pixel = pixels + y * rowBytes + left * 4; + for (int x = left; x < right; x++, pixel += 4) + { + blue += pixel[0]; + green += pixel[1]; + red += pixel[2]; + alpha += pixel[3]; + } + } + + float count = (right - left) * (bottom - top); + return new ColorBGRA( + (byte)MathF.Round(blue / count), + (byte)MathF.Round(green / count), + (byte)MathF.Round(red / count), + (byte)MathF.Round(alpha / count)).ToColor(); } /// @@ -76,6 +114,7 @@ public sealed class SKTexture : ITexture, IDisposable /// public void Dispose() { + _readback.Dispose(); Surface.Dispose(); GC.SuppressFinalize(this); } @@ -92,15 +131,15 @@ public sealed class SKTexture : ITexture, IDisposable internal void CopyPixelData() { - using SKImage skImage = Surface.Snapshot(); - _image = skImage.ToImage(); + _hasPixels = Surface.ReadPixels(_readback.Info, _readback.GetPixels(), _readback.RowBytes, 0, 0); } #endregion #region Properties & Fields - private IImage? _image; + private readonly SKBitmap _readback; + private bool _hasPixels; private readonly Dictionary _ledRects = []; /// @@ -132,4 +171,4 @@ public sealed class SKTexture : ITexture, IDisposable public bool IsInvalid { get; private set; } #endregion -} \ No newline at end of file +} From ce5fa00aed5b583f2e737d111a74a919e65cbbb4 Mon Sep 17 00:00:00 2001 From: RobertBeekman Date: Sat, 17 Oct 2026 20:37:24 +0200 Subject: [PATCH 3/3] Core - Stop sampling SKTexture after disposal --- src/Artemis.Core/RGB.NET/SKTexture.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Artemis.Core/RGB.NET/SKTexture.cs b/src/Artemis.Core/RGB.NET/SKTexture.cs index 1242f93e7..0337d2b3c 100644 --- a/src/Artemis.Core/RGB.NET/SKTexture.cs +++ b/src/Artemis.Core/RGB.NET/SKTexture.cs @@ -114,6 +114,7 @@ public sealed class SKTexture : ITexture, IDisposable /// public void Dispose() { + _hasPixels = false; _readback.Dispose(); Surface.Dispose(); GC.SuppressFinalize(this);