2

I have a control that throws events and I need to draw immediately after said events fire. The event fires around 15 times per second with a regular interval, and are handled as expected. I tried the following scenarios:

Scenario 1

In XAML, I created a canvas. Whenever events from custom control fire, I update a counter. When the CompositionTarget event fires and the counter has changed, the canvas gets redrawn (based on the counter). However, this is too slow. The CompositionTarget event fires at a low speed and is irregular. See:

https://learn.microsoft.com/en-us/dotnet/desktop/wpf/graphics-multimedia/how-to-render-on-a-per-frame-interval-using-compositiontarget?view=netframeworkdesktop-4.8

Why is Frame Rate in WPF Irregular and Not Limited To Monitor Refresh?

Scenario 2

I installed the SkiaSharp WPF Nuget packages. Tried basically the same thing as in Scenario 1, this time using the OnPaintSurface event. But I get more-or-less the same results, it seems that OnPaintService is called in a similar way as CompositionTarget.

Demo (notice the irregular updates, sometimes frames hanging or dropping):

enter image description here

Code from demo:

<Window x:Class="TMCVisualizer.AlphaWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:TMCVisualizer" xmlns:skia="clr-namespace:SkiaSharp.Views.WPF;assembly=SkiaSharp.Views.WPF" xmlns:skiasharp="clr-namespace:SkiaSharp;assembly=SkiaSharp" mc:Ignorable="d" 
            Title="AlphaWindow" Width="1000" Height="480" WindowStyle="SingleBorderWindow" AllowsTransparency="False" ContentRendered="Handle_ContentRendered">

        <Grid Background="Beige" x:Name="grid">
            <skia:SKElement PaintSurface="OnPaintSurface" IgnorePixelScaling="True" Height="210" VerticalAlignment="Bottom" x:Name="board"></skia:SKElement>
        </Grid>
</Window>
//Removed attaching event handlers and such for clarity
private float _index = -1;

private void OnCustomControlEvent(float counter, DateTime dateTime)
{
    _index = counter;
    this.Dispatcher.Invoke(() =>
    {
        board.InvalidateVisual();
        //Will trigger redraw => OnPaintSurface
    });
}

private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
{
    var canvas = e.Surface.Canvas;
    canvas.Clear(SKColors.Transparent);

    var paint = new SKPaint
    {
        Color = SKColors.Black,
        IsAntialias = true,
        Style = SKPaintStyle.Fill,
        TextAlign = SKTextAlign.Center,
        TextSize = 24
    };
    
    var coord = new SKPoint(e.Info.Width / 2, (e.Info.Height + paint.TextSize) / 2);
    canvas.DrawText(_index.ToString(), coord, paint);
}

Also, I tried to directly draw on a writeable bitmap using SkiaSharp, again with the same results. The code I wrote looks similar to this:

The most efficient way to draw in SkiaSharp without using PaintSurface event

Is there any way to draw directly in WPF? Maybe I'm missing something, or maybe I don't fully understand SkiaSharp yet? Is there an alternative package I can use, such as SkiaSharp? I'd hate to say goodbye to WPF, because there are some other WPF components that I need to use in my app.

Edit : What I am trying to achieve:

My "component" that fires the events is a mod music player (SharpMik). I want to draw the notes that are being played in the player on screen (like a music tracker). When using the OnPaintSurface and a regular canvas, I couldn't get a better result than this (when the music is playing at the same time you see that the notes are not properly updated with the beat (slight delays)):

enter image description here

Code for the above result:

<Window x:Class="TMCVisualizer.AlphaWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:TMCVisualizer" mc:Ignorable="d" 
        Title="AlphaWindow" Width="1000" Height="480" WindowStyle="SingleBorderWindow" AllowsTransparency="False" ContentRendered="Handle_ContentRendered">
    <Canvas x:Name="canvas" Height="210" VerticalAlignment="Bottom" />
</Window>
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;
using SharpMik;
using SharpMik.Drivers;
using SharpMik.Player;
using SharpMik.Player.Events.EventArgs;

namespace TMCVisualizer
{
    public partial class AlphaWindow : Window
    {
        public Module ModModule;
        public MikMod ModPlayer;
        private Dictionary<int, List<TextBlock>> _dict = new Dictionary<int, List<TextBlock>>();
        public List<Row> Rows;
        private string _track = @"C:\Users\Wroah\Documents\MEKX-RMB.S3M";
        private float _index = -1F;

        public AlphaWindow()
        {
            InitializeComponent();
            ((App)Application.Current).WindowPlace.Register(this);

            this.Loaded += Handle_AlphaWindow_Loaded;
        }

        private void Handle_AlphaWindow_Loaded(object sender, RoutedEventArgs e)
        {
            ModPlayer = new MikMod();
            ModPlayer.Init<NaudioDriver>("");
        }

        private void SetupAndPlay()
        {
            CompositionTarget.Rendering -= Handle_Tick;

            ModModule = ModPlayer.LoadModule(_track);
            Rows = ModPlayer.Export(ModModule);

            DrawGrid();

            //Load again
            ModModule = ModPlayer.LoadModule(_track);

            this.KeyUp += Handle_AlphaWindow_KeyUp;

            CompositionTarget.Rendering += Handle_Tick;
        }

        private void Handle_AlphaWindow_KeyUp(object sender, KeyEventArgs e)
        {
            if (e.Key == Key.Space)
            {
                Task.Run(() =>
                {
                    ModPlayer.Play(ModModule);
                });
            }
        }

        private void Handle_Tick(object sender, EventArgs e)
        {
            var index = ModPlayer.GetCurrentIndex();

            if (index != _index)
            {
                _index = index;
                UpdateGrid();
            }
        }

        private void Handle_ContentRendered(object sender, EventArgs e)
        {
            SetupAndPlay();
        }

        private void UpdateGrid()
        {
            for (var i = 0; i < 13; i++)
            {
                var dictIndex = (int)_index;
                dictIndex -= 7;
                dictIndex += i;
                var hasData = dictIndex >= 0;
                var list = _dict[i];
                var cols = ModModule.numchn;
                Row row = null;

                if (hasData)
                {
                    row = Rows[dictIndex];
                }

                for (var j = 0; j <= cols; j++)
                {
                    if (j == 0)
                    {
                        //Draw pattern position counter
                        if (hasData)
                            list[0].Text = row.Patpos.ToString("D2");
                        else
                            list[0].Text = "";
                    }
                    else
                    {
                        if (hasData)
                            list[j].Text = row.Cols[j - 1].note;
                        else
                            list[j].Text = ".";
                    }
                }
            }
        }

        private void DrawGrid()
        {
            var xPos = 0;
            var yPos = 1;
            var width = canvas.ActualWidth;

            canvas.Children.Clear();
            canvas.Background = Brushes.Black;

            for (var i = 0; i < 13; i++)
            {
                var line = new Line();

                line.X1 = xPos;
                line.X2 = width;
                line.Y1 = yPos;
                line.Y2 = yPos;

                line.StrokeThickness = (int)1;
                line.SnapsToDevicePixels = true;
                line.SetValue(RenderOptions.EdgeModeProperty, EdgeMode.Aliased);
                line.Stroke = Brushes.Gray;

                if (i == 6)
                {
                    var brush = new SolidColorBrush(Colors.White);
                    brush.Opacity = .7;

                    var rect = new Rectangle();
                    rect.Width = width;
                    rect.Height = 15;
                    rect.Fill = brush;
                    rect.SnapsToDevicePixels = true;

                    Canvas.SetLeft(rect, 0);
                    Canvas.SetTop(rect, yPos);

                    canvas.Children.Add(rect);
                }

                canvas.Children.Add(line);

                var list = new List<TextBlock>();

                for (var j = 0; j < 64; j++)
                {
                    var txt = new TextBlock();
                    txt.FontFamily = new FontFamily("Consolas");
                    txt.Width = 30;
                    txt.Foreground = Brushes.White;

                    if (i == 6)
                        txt.Foreground = Brushes.Black;

                    txt.TextAlignment = TextAlignment.Center;
                    txt.SnapsToDevicePixels = true;
                    txt.IsEnabled = false;

                    Canvas.SetTop(txt, yPos);
                    Canvas.SetLeft(txt, j * 30);

                    canvas.Children.Add(txt);

                    list.Add(txt);
                }

                _dict.Add(i, list);

                yPos += 15;
            }
        }
    }
}
Gerhard Schreurs
  • 663
  • 1
  • 8
  • 19
  • 1
    Why would you not "redraw the Canvas" immediately when the event is fired? 15 such redraws per second don't seem particularly unreasonable. – Clemens Nov 03 '21 at 14:22
  • I tried doing it like this : https://stackoverflow.com/questions/65495408/the-most-efficient-way-to-draw-in-skiasharp-without-using-paintsurface-event. I don't understand why, but I get more or less the same results. Maybe WPF itself will only redraw whatever is on the canvas after CompositionTarget event fired? – Gerhard Schreurs Nov 03 '21 at 14:25
  • 1
    What's the purpose of using SkiaSharp? What exactly are you drawing? – Clemens Nov 03 '21 at 14:29
  • I tried SkiaSharp because I understand that it's much faster for drawing purposes and allows me to draw directly on screen. I will update my question with what I'm trying to achieve. – Gerhard Schreurs Nov 03 '21 at 14:34
  • Just did that ;) – Gerhard Schreurs Nov 03 '21 at 14:40
  • Well, we don't see any code that draws anything. There is certainly a chance to optimize it. Make sure not to recreate all those UI elements on each update, but instead just update their view-related properties. – Clemens Nov 03 '21 at 14:46
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/238841/discussion-between-gerhard-schreurs-and-clemens). – Gerhard Schreurs Nov 03 '21 at 14:47
  • Anyway, updated question with source code from example 1 – Gerhard Schreurs Nov 03 '21 at 15:08
  • From a quick look, what I already said: do not re-create all those Lines and TextBlocks each time, but just create then once and later only update their properties. – Clemens Nov 03 '21 at 15:56
  • That's exactly what I am doing. We have two routines, DrawGrid and UpdateGrid. In UpdateGrid I'm just updating textblocks, not redrawing them. The problem is, for what I can see, that CompositionTarget.Rendering is not firing fast enough and at a precise rate, thus "stuttering" occures in the animation – Gerhard Schreurs Nov 03 '21 at 16:00
  • 1
    Not sure why you think you need to use CompositionTarget.Rendering. Redraw when the event is fired. – Clemens Nov 03 '21 at 16:05
  • Ok, my code is a mess right now, but WPF performance is indeed not the problem. I removed the CompositionTarget.Rendering and instead of using the event from SharpMik, I simply wrote a test while loop to trigger GridUpdate every 50ms. Works like a charm. Must be a timing issue. Thank you for your help! Still, I need to figure out what is going wrong; I need to sync with the speed of the music, which can change during playback. I try to improve code and see if I can fix it. – Gerhard Schreurs Nov 03 '21 at 21:23

1 Answers1

2

What I learned, with trial and error and the feedback from user Clemens (many thanks!) is the following.

  • If you want to draw fast, don't use the CompositionTarget event. Draw directly instead. Needless to say, try to keep the amount of drawing needed to a minimum.
  • When the timing of drawing is critical (e.g. animation), I'd say, don't rely on events too much. My event invoking program wasn't too precise.
  • For now, WPF drawing speed is fast enough. If I run into serious problems, I might check out if MonoGame / SkiaSharp are tools that can improve performance.

What I did in order to fix my problem, was calculating the amount of elapsed ticks between each "frame" beforehand. Then, when the music starts playing, I start updating the screen in a loop. I draw a frame, and wait for the amount of ticks until the next frame. To keep everything in sync, I compare the amount of music elapsed ticks with the animation elapsed ticks and change the frame speed accordingly, if that makes any sense ;)

EDIT:

Though I got great speed improvements, it wasn't enough. I switched to MonoGame. I also learned that classic WinForms has slightly better performance than WPF.

Gerhard Schreurs
  • 663
  • 1
  • 8
  • 19