From edd7af7fc813848b7c3116ce70e78f751d7e747b Mon Sep 17 00:00:00 2001 From: maotovisk Date: Sat, 19 Sep 2026 11:50:53 -0300 Subject: [PATCH 1/2] fix: windows blur performing VERY bad. --- MapWizard.Desktop/MapWizard.Desktop.csproj | 14 +- .../Transitions/BlurSlideFadeTransition.cs | 288 +++++++++++++++--- .../Views/Controls/ModalHost.axaml.cs | 120 ++++++-- MapWizard.Tests/MapWizard.Tests.csproj | 2 +- MapWizard.Tools/MapWizard.Tools.csproj | 2 +- 5 files changed, 350 insertions(+), 76 deletions(-) diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj index 433af60..aef2a45 100644 --- a/MapWizard.Desktop/MapWizard.Desktop.csproj +++ b/MapWizard.Desktop/MapWizard.Desktop.csproj @@ -20,21 +20,21 @@ - + - + - - - - + + + + @@ -42,7 +42,7 @@ - + diff --git a/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs b/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs index 453f6a0..c7424f9 100644 --- a/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs +++ b/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs @@ -1,20 +1,27 @@ using System; +using System.Numerics; using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.Styling; namespace MapWizard.Desktop.Transitions; /// /// Page transition that blurs and slides the old page out while fading, -/// then unblurs and settles the new page in. Driven by stepped interpolation -/// so it cannot stall. +/// then unblurs and settles the new page in. All animated properties are +/// render-only; the blur is interpolated as an Avalonia effect and rendered +/// by the Skia backend. /// public sealed class BlurSlideFadeTransition : IPageTransition { + private readonly SemaphoreSlim _transitionGate = new(1, 1); + public TimeSpan OutDuration { get; set; } = TimeSpan.FromMilliseconds(160); public TimeSpan InDuration { get; set; } = TimeSpan.FromMilliseconds(300); @@ -25,19 +32,23 @@ public sealed class BlurSlideFadeTransition : IPageTransition public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) { - var direction = forward ? 1d : -1d; + // TransitioningContentControl can start the replacement transition before + // the canceled transition has unwound. Let the old transition restore its + // compositor state first so it cannot leave (or later reapply) an offset. + await _transitionGate.WaitAsync(cancellationToken); - // TransitioningContentControl adds the incoming presenter before calling Start. - // Hide it synchronously so it cannot paint a frame over the outgoing page. - if (to is not null) - { - to.IsVisible = false; - to.Opacity = 0d; - to.RenderTransform = new TranslateTransform(direction * SlideDistance, 0d); - } + var direction = forward ? 1d : -1d; try { + // TransitioningContentControl adds the incoming presenter before calling Start. + // Hide it synchronously so it cannot paint a frame over the outgoing page. + if (to is not null) + { + ResetVisualState(to); + to.IsVisible = false; + } + if (from is not null) { await AnimateOutAsync(from, direction, cancellationToken); @@ -54,62 +65,247 @@ public async Task Start(Visual? from, Visual? to, bool forward, CancellationToke { if (from is not null) { - from.Effect = null; - from.Opacity = 1d; - from.RenderTransform = new TranslateTransform(0d, 0d); + ResetVisualState(from); } if (to is not null) { - to.Effect = null; - to.Opacity = 1d; + ResetVisualState(to); to.IsVisible = true; - to.RenderTransform = new TranslateTransform(0d, 0d); } + + _transitionGate.Release(); } } - private async Task AnimateOutAsync(Visual from, double direction, CancellationToken cancellationToken) + private static void ResetVisualState(Visual visual) { - var blur = new BlurEffect { Radius = 0d }; - var translation = new TranslateTransform(); - from.Effect = blur; - from.RenderTransform = translation; + visual.Effect = null; + visual.Opacity = 1d; + visual.RenderTransform = new TranslateTransform(0d, 0d); - const int steps = 10; - for (var i = 1; i <= steps; i++) + if (ElementComposition.GetElementVisual(visual) is { } compositionVisual) { - var t = SmoothStep((double)i / steps); - from.Opacity = 1d - t; - translation.X = -direction * SlideDistance * t; - blur.Radius = MaxBlurRadius * t; - await Task.Delay(OutDuration / steps, cancellationToken); + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Translation"); + compositionVisual.Opacity = 1f; + compositionVisual.Translation = default; } } + private async Task AnimateOutAsync(Visual from, double direction, CancellationToken cancellationToken) + { + await AnimateAsync( + from, + OutDuration, + new CubicEaseIn(), + startOpacity: 1d, + endOpacity: 0d, + startOffset: 0d, + endOffset: -direction * SlideDistance, + startBlurRadius: 0d, + endBlurRadius: MaxBlurRadius, + revealBeforeAnimation: false, + cancellationToken); + } + private async Task AnimateInAsync(Visual to, double direction, CancellationToken cancellationToken) { - var blur = new BlurEffect { Radius = MaxBlurRadius }; - var translation = new TranslateTransform(direction * SlideDistance, 0d); - to.Effect = blur; - to.Opacity = 0d; - to.RenderTransform = translation; - to.IsVisible = true; - - const int steps = 14; - for (var i = 1; i <= steps; i++) + await AnimateAsync( + to, + InDuration, + new CubicEaseOut(), + startOpacity: 0d, + endOpacity: 1d, + startOffset: direction * SlideDistance, + endOffset: 0d, + startBlurRadius: MaxBlurRadius, + endBlurRadius: 0d, + revealBeforeAnimation: true, + cancellationToken); + } + + private static async Task AnimateAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + double startOffset, + double endOffset, + double startBlurRadius, + double endBlurRadius, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var compositionVisual = ElementComposition.GetElementVisual(target); + if (compositionVisual is null) { - var t = SmoothStep((double)i / steps); - to.Opacity = t; - translation.X = direction * SlideDistance * (1d - t); - blur.Radius = MaxBlurRadius * (1d - t); - await Task.Delay(InDuration / steps, cancellationToken); + await AnimateFallbackAsync( + target, + duration, + easing, + startOpacity, + endOpacity, + startOffset, + endOffset, + startBlurRadius, + endBlurRadius, + revealBeforeAnimation, + cancellationToken); + return; + } + + var previousOpacity = compositionVisual.Opacity; + var previousTranslation = compositionVisual.Translation; + var startTranslation = new Vector3( + (float)(previousTranslation.X + startOffset), + (float)previousTranslation.Y, + (float)previousTranslation.Z); + var endTranslation = new Vector3( + (float)(previousTranslation.X + endOffset), + (float)previousTranslation.Y, + (float)previousTranslation.Z); + + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Translation"); + compositionVisual.Opacity = (float)startOpacity; + compositionVisual.Translation = startTranslation; + + // The effect animation is the Skia-rendered path. Opacity and translation + // remain compositor animations because they do not redraw or invalidate layout. + target.Effect = new BlurEffect { Radius = endBlurRadius }; + var blurAnimation = CreateBlurAnimation(startBlurRadius, endBlurRadius, duration, easing) + .RunAsync(target, cancellationToken); + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + StartCompositionAnimation( + compositionVisual, + duration, + easing, + (float)startOpacity, + (float)endOpacity, + startTranslation, + endTranslation); + + try + { + await Task.WhenAll( + blurAnimation, + Task.Delay(duration, cancellationToken)); + target.Effect = new BlurEffect { Radius = endBlurRadius }; + } + finally + { + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Translation"); + compositionVisual.Opacity = previousOpacity; + compositionVisual.Translation = previousTranslation; } } - private static double SmoothStep(double t) + private static void StartCompositionAnimation( + CompositionVisual visual, + TimeSpan duration, + Easing easing, + float startOpacity, + float endOpacity, + Vector3 startTranslation, + Vector3 endTranslation) { - t = Math.Clamp(t, 0d, 1d); - return t * t * (3d - 2d * t); + var opacity = visual.Compositor.CreateScalarKeyFrameAnimation(); + opacity.Target = "Opacity"; + opacity.Duration = duration; + opacity.InsertKeyFrame(0f, startOpacity); + opacity.InsertKeyFrame(1f, endOpacity, easing); + visual.StartAnimation("Opacity", opacity); + + var translation = visual.Compositor.CreateVector3KeyFrameAnimation(); + translation.Target = "Translation"; + translation.Duration = duration; + translation.InsertKeyFrame(0f, startTranslation); + translation.InsertKeyFrame(1f, endTranslation, easing); + visual.StartAnimation("Translation", translation); + } + + private static async Task AnimateFallbackAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + double startOffset, + double endOffset, + double startBlurRadius, + double endBlurRadius, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var translation = new TranslateTransform(startOffset, 0d); + target.Opacity = startOpacity; + target.RenderTransform = translation; + target.Effect = new BlurEffect { Radius = endBlurRadius }; + + var blurAnimation = CreateBlurAnimation(startBlurRadius, endBlurRadius, duration, easing) + .RunAsync(target, cancellationToken); + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + var steps = Math.Max(1, (int)Math.Ceiling(duration.TotalMilliseconds / 16d)); + var stepDuration = TimeSpan.FromTicks(duration.Ticks / steps); + + for (var step = 1; step <= steps; step++) + { + var progress = easing.Ease((double)step / steps); + target.Opacity = startOpacity + ((endOpacity - startOpacity) * progress); + translation.X = startOffset + ((endOffset - startOffset) * progress); + await Task.Delay(stepDuration, cancellationToken); + } + + await blurAnimation; + } + + private static Animation CreateBlurAnimation( + double startRadius, + double endRadius, + TimeSpan duration, + Easing easing) + { + return new Animation + { + Duration = duration, + Easing = easing, + FillMode = FillMode.Forward, + Children = + { + new KeyFrame + { + Cue = new Cue(0d), + Setters = + { + new Setter( + Visual.EffectProperty, + new BlurEffect { Radius = startRadius }), + }, + }, + new KeyFrame + { + Cue = new Cue(1d), + Setters = + { + new Setter( + Visual.EffectProperty, + new BlurEffect { Radius = endRadius }), + }, + }, + }, + }; } } diff --git a/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs b/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs index 01ed680..38613ef 100644 --- a/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs +++ b/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs @@ -2,11 +2,14 @@ using System.Threading; using System.Threading.Tasks; using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Media.Transformation; +using Avalonia.Styling; using MapWizard.Desktop.Services; namespace MapWizard.Desktop.Views.Controls; @@ -23,7 +26,10 @@ public partial class ModalHost : UserControl private int _transitionVersion; private Visual? _blurredTarget; private IEffect? _previousBackgroundEffect; - private BlurEffect? _backgroundBlurEffect; + private Visual? _backgroundCacheTarget; + private CacheMode? _previousBackgroundCacheMode; + private CancellationTokenSource? _backgroundBlurAnimationCancellation; + private double _backgroundBlurRadius; public static readonly StyledProperty IsOpenProperty = AvaloniaProperty.Register(nameof(IsOpen)); @@ -288,7 +294,7 @@ private void PrepareBackgroundBlur() return; } - if (ReferenceEquals(target, _blurredTarget) && _backgroundBlurEffect is not null) + if (ReferenceEquals(target, _blurredTarget)) { return; } @@ -296,8 +302,19 @@ private void PrepareBackgroundBlur() ClearBackgroundBlur(); _blurredTarget = target; _previousBackgroundEffect = target.Effect; - _backgroundBlurEffect = new BlurEffect { Radius = 0d }; - target.Effect = _backgroundBlurEffect; + _backgroundBlurRadius = 0d; + + // Cache the shell's content below the effect-bearing border. Caching + // the border itself would also cache the animated effect, making the + // transition appear to jump directly to its final frame. + if (target is Decorator { Child: { } child }) + { + _backgroundCacheTarget = child; + _previousBackgroundCacheMode = child.CacheMode; + child.CacheMode ??= new BitmapCache(); + } + + target.Effect = new BlurEffect { Radius = 0d }; } private async Task AnimateBackgroundBlurAsync( @@ -306,47 +323,108 @@ private async Task AnimateBackgroundBlurAsync( int version, CancellationToken cancellationToken) { - var blur = _backgroundBlurEffect; - if (blur is null) + var target = _blurredTarget; + if (target is null) { return; } - var startRadius = blur.Radius; - const int steps = 16; - var stepDuration = TimeSpan.FromTicks(duration.Ticks / steps); + // Reading before cancellation captures the currently animated effect, + // allowing a close that interrupts an open to reverse without a jump. + var startRadius = target.Effect is IBlurEffect blur + ? blur.Radius + : _backgroundBlurRadius; + + _backgroundBlurAnimationCancellation?.Cancel(); + var animationCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _backgroundBlurAnimationCancellation = animationCancellation; + + // Keep the destination as the base value. When RunAsync removes its + // animation value, the rendered effect therefore remains at the end. + target.Effect = new BlurEffect { Radius = targetRadius }; try { - for (var step = 1; step <= steps; step++) - { - await Task.Delay(stepDuration, cancellationToken); - if (version != _transitionVersion || !ReferenceEquals(blur, _backgroundBlurEffect)) - { - return; - } + await CreateBackgroundBlurAnimation(startRadius, targetRadius, duration) + .RunAsync(target, animationCancellation.Token); - var progress = (double)step / steps; - var easedProgress = progress * progress * (3d - (2d * progress)); - blur.Radius = startRadius + ((targetRadius - startRadius) * easedProgress); + if (version == _transitionVersion && ReferenceEquals(target, _blurredTarget)) + { + _backgroundBlurRadius = targetRadius; + target.Effect = new BlurEffect { Radius = targetRadius }; } } catch (OperationCanceledException) { - // A close or a replacement transition continues from the current radius. + // A replacement transition continues from the animated radius. + } + finally + { + if (ReferenceEquals(_backgroundBlurAnimationCancellation, animationCancellation)) + { + _backgroundBlurAnimationCancellation = null; + } + + animationCancellation.Dispose(); } } + private static Animation CreateBackgroundBlurAnimation( + double startRadius, + double endRadius, + TimeSpan duration) + { + return new Animation + { + Duration = duration, + Easing = new CubicEaseInOut(), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame + { + Cue = new Cue(0d), + Setters = + { + new Setter( + Visual.EffectProperty, + new BlurEffect { Radius = startRadius }), + }, + }, + new KeyFrame + { + Cue = new Cue(1d), + Setters = + { + new Setter( + Visual.EffectProperty, + new BlurEffect { Radius = endRadius }), + }, + }, + }, + }; + } + private void ClearBackgroundBlur() { + _backgroundBlurAnimationCancellation?.Cancel(); + _backgroundBlurAnimationCancellation = null; + if (_blurredTarget is not null) { _blurredTarget.Effect = _previousBackgroundEffect; } + if (_backgroundCacheTarget is not null) + { + _backgroundCacheTarget.CacheMode = _previousBackgroundCacheMode; + } + _blurredTarget = null; _previousBackgroundEffect = null; - _backgroundBlurEffect = null; + _backgroundCacheTarget = null; + _previousBackgroundCacheMode = null; + _backgroundBlurRadius = 0d; } private void BackdropBorder_OnPointerPressed(object? sender, PointerPressedEventArgs e) diff --git a/MapWizard.Tests/MapWizard.Tests.csproj b/MapWizard.Tests/MapWizard.Tests.csproj index 5700115..64a198b 100644 --- a/MapWizard.Tests/MapWizard.Tests.csproj +++ b/MapWizard.Tests/MapWizard.Tests.csproj @@ -21,7 +21,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/MapWizard.Tools/MapWizard.Tools.csproj b/MapWizard.Tools/MapWizard.Tools.csproj index bc32a88..dadff60 100644 --- a/MapWizard.Tools/MapWizard.Tools.csproj +++ b/MapWizard.Tools/MapWizard.Tools.csproj @@ -9,7 +9,7 @@ - + From 568aacf8f05b16640fb9bceb6d8ce62710f09833 Mon Sep 17 00:00:00 2001 From: maotovisk Date: Sat, 19 Sep 2026 12:14:59 -0300 Subject: [PATCH 2/2] feat: implement new animations --- MapWizard.Desktop/Program.cs | 1 + .../Controls/BeatmapSelectionPanel.axaml | 34 +++++++++---------- .../Controls/BeatmapSelectionPanel.axaml.cs | 23 +++++++++++-- .../Views/Dialogs/SongSelectDialog.axaml.cs | 5 +-- 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/MapWizard.Desktop/Program.cs b/MapWizard.Desktop/Program.cs index 42e628a..1c0d6c7 100644 --- a/MapWizard.Desktop/Program.cs +++ b/MapWizard.Desktop/Program.cs @@ -55,6 +55,7 @@ private static AppBuilder BuildAvaloniaApp(bool forceSoftwareRendering) ] }); } + return app; } diff --git a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml index 13d4f0a..97d5414 100644 --- a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml +++ b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml @@ -226,16 +226,6 @@ - -