Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[wip] implement SkiaSharp PlotViews #71

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

Jonarw
Copy link
Member

@Jonarw Jonarw commented Aug 7, 2024

This adds two new PlotViews that can be used as a direct replacement of the existing OxyPlot.Avalonia.PlotView:

  • OxyPlot.SkiaSharp.Avalonia.PlotView
  • OxyPlot.SkiaSharp.Avalonia.DoubleBuffered.PlotView

Both of the new PlotViews use OxyPlot.SkiaSharp for rendering and only work when SkiaSharp is used as the rendering backend for Avalonia. As far as I understand, this is the case most of the time (but not always).

OxyPlot.SkiaSharp.Avalonia.PlotView overrides Avalonias Visual.Render method, leases a SkCanvas from Avalonia in a ICustomDrawOperation and uses this to render the plot. In terms of performance in comparison to CanvasRenderContext, this is a mixed bag; some examples are significantly faster, others quite a bit slower - at least that is the case on my machine.
Similar to OxyPlot.Avalonia.PlotView, updating and rendering always takes place on the UI thread. So probably in most cases there is not much incentive to switch from OxyPlot.Avalonia.PlotView to OxyPlot.SkiaSharp.Avalonia.PlotView, it's rather just an alternative solution that has some advantages and some drawbacks depending on the specific application.

Much more interesting is OxyPlot.SkiaSharp.Avalonia.DoubleBuffered.PlotView: This uses OxyPlot.SkiaSharp to render the plot into an in-memory Bitmap in a double-buffer configuration. This has the nice implication that rendering and updating can be done completely on a background thread; the UI thread only needs to draw the finished bitmap on the screen. This can help a lot with keeping the UI responsive even when rendering very complicated plots.

This PR is not quite finished, there are still some things I need to fix, improve and verify. But at least the ExampleBrowser is fully functional and allows switching between the three PlotViews.
I am not quite sure how much time I will have to work on this for the next couple of weeks, but I will definitely pick this up afterwards and finish it up. In the meantime, I thought I'd open this PR so people who are interested can play around with it. Feedback would be very welcome, espcially since I am fairly new to Avalonia (coming from WPF).

while (true)
{
cancellationToken.ThrowIfCancellationRequested();
this.renderRequiredEvent.WaitOne();
Copy link
Contributor

@VisualMelon VisualMelon Aug 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if the guidance has changed on this (and I'm just glancing at the PR, not taken a proper look yet, so may be talking nonsense), but I don't think locking up a thread like this is ideal. Using https://learn.microsoft.com/en-us/dotnet/api/system.threading.semaphoreslim.waitasync?view=net-8.0 or similar would allow it to run on the thread-pool and may be a better solution

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct of course, and usually I would prefer not to block a thread here.
However SemaphoreSlim does not really cover the use-case we need from the AutoResetEvent here: It's possible that updates are requested more often than they can be performed, and I don't think this scenario really works with a semaphore. Usually I'd like to use AsyncAutoResetEvent from AsyncEx for this purpose, but I didn't want to bring in this dependency just for this. Shame that .NET doesn't provide stuff like this out of the box...

Copy link
Contributor

@VisualMelon VisualMelon Aug 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SemaphoreSlim+CAS has always been my go-to for this; as you say, it's annoying that there's no equivalent method on AutoResetEvent. Not familiar enough with the code to say for sure, but if we move the CAS logic earlier it should implicitly solve that problem: I'm happy to look into this in more detail.

(another option is to just start a new Task as necessary; I'm not sure how that would compare performance wise; as I recall the latency on wait methods is pretty terrible as it is; or probably best of all is just to use a TCS like the semaphore does already https://referencesource.microsoft.com/#mscorlib/system/threading/SemaphoreSlim.cs,695)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not quite sure how this could be made to work with a SemaphoreSlim, but I would be curious to find out if that is possible. So you are quite welcome to look into this if you are motivated :)

Copy link
Contributor

@VisualMelon VisualMelon Aug 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea is that you use a dirty flag to decide whether the code should actually run (it already does this for update vs render), then the semaphore will just spin quickly until the count is 0 (improvement is then to use the flags to not set the semaphore unless it needs to be set). I'll stop posting inane commentary here, and hopefully take a proper look at the whole PR over the weekend ;)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is definitely room for improvement here, I just realized that the isRenderRequired flag isn't actually used anywhere. And thinking about it, I guess this makes sense, because in the double-buffer configuration we only need to render when something actually has changed in the PlotModel...

Copy link
Contributor

@VisualMelon VisualMelon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few of these comments are just remarks about Avalonia vs WPF, so treat them however you want. Other observations:

  • XAML Bar Series example is broken (missing the blue series), and it crashes if you try to hide one of the series by clicking in the legend.
    Not looked at the threading properly yet; will have a play with that next.

Source/Examples/Avalonia/ExampleBrowser/Category.cs Outdated Show resolved Hide resolved
Source/Examples/Avalonia/ExampleBrowser/MainWindow.axaml Outdated Show resolved Hide resolved
Source/Examples/Avalonia/ExampleBrowser/MainWindow.axaml Outdated Show resolved Hide resolved
Comment on lines +12 to +13
OxyPlot.Avalonia.OxyPlotModule.EnsureLoaded();
OxyPlot.SkiaSharp.Avalonia.OxyPlotModule.EnsureLoaded();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What purpose do these serve?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea, I have this from some Avalonia example which I can't find anymore. This seemed to be required at some point when I was testing, but I just tried without these lines and it seems to work fine...

Copy link
Contributor

@VisualMelon VisualMelon Aug 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had wondered if it was to force the XML namespaces or something, but I don't know.

If it works without, I'd say scrub them until we have reason to put them back in.

Source/OxyPlot.Avalonia.Shared/PlotBase.cs Outdated Show resolved Hide resolved
/// </summary>
protected PlotBase()
{
this.TrackerDefinitions = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really going to annoy me: hard not to see this as an immutable list. I assume these are automatic fixes? (I'd prefer the full type was here, given it's opaque; new() would be preferable to [] otherwise, but this is just personal preference)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes those are automated fixes which are offered on latest Visual Studio on default settings. I don't mind this notation and don't have the association with immutable lists, but I can see how this might be an opinionated matter.

There are a couple of issues regarding code style in this PR. The problem is that there are neither clear guidelines (even less so than in the core repo) nor a .editorconfig which could be used to make sure the style is ok.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I just need to get with the times; it's fine. Could pull the .editorconfig from the core repo, that would be fine by me


namespace OxyPlot.SkiaSharp.Avalonia
{
public class PlotRenderer(PlotView plotView) : Control, IDisposable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be better called SkiaPlotRenderer?

/// <param name="updateData">The update Data.</param>
public virtual void InvalidatePlot(bool updateData = true)
{
this.isUpdateRequired = updateData ? 2 : 1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't sufficient: may change a 2 to a 1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public void InvalidatePlot(bool updateData = true)

Old code's purpose is to ensure we only ever increase the value (so we can't go from requesting an update with data-changes to an update without data-changes) and only fires off a request if there isn't one active already (i.e. if the user calls plot.InvalidatePlot(true) followed immediately by plot.InvalidatePlot(false), the old code will ensure that if the work isn't picked up by the thread doing the update before the second call that it will still observe the updateData=true bit and it will only be invoked once)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the code to make sure a 2 is not overwritten by a 1. Regarding the rest of the old behaviour (only one invoke for consecutive update calls), I will have to look into it some more later.

Comment on lines +3 to +5
xmlns:local3="http://oxyplot.org/skiasharp/avalonia/doublebuffered"
xmlns:local2="http://oxyplot.org/skiasharp/avalonia"
xmlns:local="http://oxyplot.org/avalonia"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These could do with better names

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to share the Tracker theme somehow, but I can't get it work with the shared project (never really used them, so I've no idea what I'm doing)

{
return new OxyRect(rect.Left, rect.Top, rect.Width, rect.Height);
}
public static Rect ToRect(this OxyRect rect)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Micronit: Probably wants a line break between these


private void StartRenderLoop()
{
new Task(() =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because all this stuff is deferred, I think it's possible for it to be started by being attached, and then be detached before it gets around to creating the CTS, and so run when not attached.

this.StopRenderLoop();

// release renderRequiredEvent once more in case the render loop currently is waiting for the mutex; otherwise it might wait forever and will not be canceled
this.RequestRender();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can switch to using async for the render loop, can then await the cancellation token rather than having to pulse it when we are disposing. As is, I think this should be in StopRenderLoop so that OnDetachedFromVisualTree also ensures the render loop doesn't sit waiting.

@Jonarw
Copy link
Member Author

Jonarw commented Sep 29, 2024

Latest commits make the render loop of the double buffered renderer async. I'm not a huge fan of the wait-loop to spin down the SemaphoreSlim, but I think this is the only way in the absence of an async AutoResetEvent. Overall I think this is an improvement.
Also reviewed the usage of the dirty flags, should make more sense now.

There is still some work to do, to be continued.

@kekekeks
Copy link
Contributor

kekekeks commented Sep 29, 2024

Quick question. Why won't you use SKPictureRecorder and then wrap the resulting SKPicture into ICustomDrawOperation instead of rasterizing into SKBitmap? Is the bottleneck the rasterization itself?

@Jonarw
Copy link
Member Author

Jonarw commented Sep 29, 2024

Summing up my understandig of this topic, please correct if I am wrong:

  • SKPictureRecorder would allow us to offload the 'conversion' of a PlotModel to drawing commands (i.e. IPlotModel.Render) to a background thread. The rasterization of these drawing commands would be issued by Avalonia, and happen on the UI thread.

  • The double-buffer approach allows to offload both the generation of drawing commands and rasterization on a background thread, the UI thread only needs to draw a bitmap.

I have not tried this, but I assume that rasterization might be fairly expensive as well, of course depending on the plot. But I have not actually evaluated how the two compare in terms of performance, I will look into it when I find time. Thanks for the suggestion, I had not thought of this before!

@VisualMelon
Copy link
Contributor

I'll try to take another look at this next weekend

@kekekeks
Copy link
Contributor

Avalonia does not do any drawing on the UI thread. The DrawingContext you get in the Render method simply records drawing commands to be replayed on the render thread using a GPU-accelerated graphics context.

@Jonarw
Copy link
Member Author

Jonarw commented Oct 3, 2024

Ah I see, that sounds great! I will definitely give this a try.
I think I might implement this approach as a third PlotView variant and than we can evaluate which ones are redundant and which are worth keeping.

@Jonarw
Copy link
Member Author

Jonarw commented Nov 11, 2024

Finally found some time to give the SKPictureRecorder appoach a shot.
The implementation is not quite finished, the main concern is that SKPictures are not properly disposed of, some thought would have to go into how to do that.
However it should work mostly fine and can be played around with in the ExampleBrowser.

Here is my summary of the three approaches, as far as I am concerned:

  • SkiaSharp
    Renders directly to a SKCanvas
    Good: Simple approach, no unnecessary memory overhead
    Bad: Blocks UI while rendering complex PlotModels

  • SkiaSharp.DoubleBuffered
    Renders to an in-memory bitmap on a background thread
    Good: Most of the work (plot render + update) happens on a background thread, the render thread does minimal work. Does not block UI even when rendering very complex PlotModels
    Bad: Somewhat more complex, extra memory usage for double buffer bitmaps, proper locking of PlotModel on user side is crucial

  • SkiaSharp.PictureRecorder
    Records the drawing operations necessary to render a PlotModel in a SKPicture and uses this to draw the plot
    Good: Plot update can happen on a background thread. Rendering may take advantage of GPU acceleration (though I didn't notice much difference during my testing)
    Bad: Blocks UI while rendering complex PlotModels (I guess while the render thread is busy rendering the plot, it just can't render anything else). Extra memory usage for recorded rendering operations, proper locking of PlotModel on user side is crucial

For me the most compelling point of the double-buffer approach is that the UI remains responsive even when rendering very complex plots. Unfortunately, this advantage seems to be lost with the PictureRecorder approach. While this definitely also has its advantages, I am not sure if this is worth the extra complexity...

@BobLd
Copy link
Contributor

BobLd commented Nov 11, 2024

@Jonarw my 2 cents here: I've used SkPicture and its recorder to draw pdf document pages in an avalonia app: https://github.com/BobLd/Caly. The task is very similar to what you are trying to achieve here.

Regarding SkPicture dispose problems, you might want to look into Avalonia's Ref class here https://github.com/AvaloniaUI/Avalonia/blob/master/src/Avalonia.Base/Utilities/Ref.cs This is what I used and will help you count the references.

My app is still in early development stage so do not take my approach as 100% perfect.

@VisualMelon
Copy link
Contributor

@Jonarw is this ready for another review? (should have time later today if so)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants