GUI - Rhino - Radial Menu - Cross Platform

I sent you in private message the relevant code for Skia drawings.

I add a lot of performance watchers, and I finally see that the “shadow” drawing takes around 5 to 9 ms for each level. Even if I remove “antialias”, the average gain is only 2ms. Maybe I have to look in this to optimize.

Confirmed. Drop shadow takes the longest. Also around 8ms per level. Looking into this now.

1 Like

To be more specific, it’s the shadow blur:

Blurred shadow: 60ms for 10 rings

if (_drawShadow)
 {
     canvas.Save();
     canvas.Translate(12 * _screenScale, 12 * _screenScale);
     var shadowFilter = SKImageFilter.CreateDropShadowOnly(
         dx: 12 * _screenScale,
         dy: 12 * _screenScale,
         sigmaX: 6 * _screenScale,
         sigmaY: 8 * _screenScale,
         color: SKColors.Black.WithAlpha(120)
         );
     using (var shadowPaint = new SKPaint
     {
         ImageFilter = shadowFilter,
         Style = SKPaintStyle.Fill,
         Color = SKColors.Black.WithAlpha(120)
     })
     {
         canvas.DrawPath(path, shadowPaint);
     }
     canvas.Restore();
 }

non-blurred shadow: 3 ms for 10 rings:

if (_drawShadow)
 {
     canvas.Save();
     canvas.Translate(12 * _screenScale, 12 * _screenScale);
     using (var shadowPaint = new SKPaint
     {
         Style = SKPaintStyle.Fill,
         Color = SKColors.Black.WithAlpha(120)
     })
     {
         canvas.DrawPath(path, shadowPaint);
     }
     canvas.Restore();
 }

Right, but non blured shadow look « ugly »
I could pre-calculate all animations, but it’ll take around 20 seconds and mostly 1go ram :cold_face:
So for now, I’ll stick with gpu rendering (metal and vulkan if it works).
Maybe there is a moré efficient library than skia, but I didn’t find one for now.

Yeah, GPU acceleration seems to be the best performing approach for this case. But it’s very simple to do on Windows. Create a context and copy pixels from the GPU to the CPU once done:

_grContext = GRContext.CreateGl();

The following code runs in approx. 15 ms for 20 rings and 1200 x 1200 px. Scaling down to 600 x 600 px takes 7 ms

using System.Diagnostics;
using Eto.Forms;
using Eto.Drawing;
using SkiaSharp;
using Rhino.Commands;
using Rhino;

namespace SkiaSharpTest
{
    public class SkiaSharpTestCommand : Rhino.Commands.Command
    {
        public override string EnglishName => "SkiaSharpTestCommand";

        protected override Result RunCommand(RhinoDoc doc, RunMode mode)
        {
            var form = new MainDialog();
            form.ShowModal(Rhino.UI.RhinoEtoApp.MainWindow);
            return Result.Success;
        }
    }

    public class MainDialog : Dialog
    {
        public MainDialog()
        {
            Title = "SkiaSharp Animated Rings";
            ClientSize = new Size(1200, 1200);
            Location = new Point(0, 0);
            Content = new SkiaRingsDrawable();
        }
    }


internal class SkiaRingsDrawable : Drawable
    {
        private Bitmap _etoBitmap;
        private SKImageInfo _info;
        private int _numLevels = 20;
        private float _centerX = 600;
        private float _centerY = 600;
        private float _baseInnerRadius = 10;
        private float _ringWidth = 50;
        private bool _drawShadow = true;
        private float _screenScale = 1.0f;

        private SKImageFilter _shadowFilter;

        private GRContext _grContext;

        // Animation variables
        private float _radiusOffset = 0f;
        private float _offsetIncrement = 0.2f;
        private float _maxOffset = 10f;
        private bool _expanding = true;

        private UITimer _timer;

        public SkiaRingsDrawable()
        {
            _etoBitmap = new Bitmap(1200, 1200, PixelFormat.Format32bppRgba);
            _info = new SKImageInfo(
                width: _etoBitmap.Width,
                height: _etoBitmap.Height,
                colorType: SKColorType.Bgra8888,
                alphaType: SKAlphaType.Premul
            );

            _grContext = GRContext.CreateGl();

            _shadowFilter = SKImageFilter.CreateDropShadowOnly(
                dx: 12 * _screenScale,
                dy: 12 * _screenScale,
                sigmaX: 6 * _screenScale,
                sigmaY: 8 * _screenScale,
                color: SKColors.Black.WithAlpha(120)
            );

            _timer = new UITimer();
            _timer.Interval = 0.016;
            _timer.Elapsed += (sender, e) =>
            {
                UpdateAnimation();
                DrawRings();
                Invalidate();
            };
            _timer.Start();

            this.UnLoad += (sender, e) =>
            {
                _timer.Stop();
                _shadowFilter?.Dispose();
                _grContext?.Dispose();
            };
        }

        private void UpdateAnimation()
        {
            if (_expanding)
            {
                _radiusOffset += _offsetIncrement;
                if (_radiusOffset >= _maxOffset)
                {
                    _radiusOffset = _maxOffset;
                    _expanding = false;
                }
            }
            else
            {
                _radiusOffset -= _offsetIncrement;
                if (_radiusOffset <= 0)
                {
                    _radiusOffset = 0;
                    _expanding = true;
                }
            }
        }

        private void DrawRings()
        {
            var stopwatch = Stopwatch.StartNew();

            using (var bitmapData = _etoBitmap.Lock())
            {
                // Create a hardware-accelerated surface
                using (var surface = SKSurface.Create(_grContext, false, _info))
                {
                    var canvas = surface.Canvas;
                    canvas.Clear(SKColors.White);

                    using (var fillPaint = new SKPaint { Style = SKPaintStyle.Fill })
                    {
                        for (int level = 0; level < _numLevels; level++)
                        {
                            float animatedRingWidth = _ringWidth + _radiusOffset;
                            float innerRadius = _baseInnerRadius + level * animatedRingWidth;
                            float outerRadius = innerRadius + animatedRingWidth;

                            using (var path = new SKPath())
                            {
                                path.AddCircle(_centerX, _centerY, outerRadius);
                                path.AddCircle(_centerX, _centerY, innerRadius);
                                path.FillType = SKPathFillType.EvenOdd;

                                if (_drawShadow)
                                {
                                    canvas.Save();
                                    canvas.Translate(12 * _screenScale, 12 * _screenScale);

                                    using (var shadowPaint = new SKPaint
                                    {
                                        ImageFilter = _shadowFilter,
                                        Style = SKPaintStyle.Fill,
                                        Color = SKColors.Black.WithAlpha(120)
                                    })
                                    {
                                        canvas.DrawPath(path, shadowPaint);
                                    }
                                    canvas.Restore();
                                }

                                SKColor currentColor = (level % 2 == 0) ? SKColors.Blue : SKColors.Red;
                                fillPaint.Color = currentColor;
                                canvas.DrawPath(path, fillPaint);
                            }
                        }
                    }

                    canvas.Flush();

                    using (var image = surface.Snapshot())
                    {
                        image.ReadPixels(_info, bitmapData.Data, bitmapData.ScanWidth, 0, 0);
                    }
                }
            }

            stopwatch.Stop();
            Rhino.RhinoApp.WriteLine($"Frame render time: {stopwatch.Elapsed.TotalMilliseconds:F2} ms");
        }

        protected override void OnPaint(PaintEventArgs e)
        {
            base.OnPaint(e);
            e.Graphics.DrawImage(_etoBitmap, new PointF(0, 0));
        }
    }
}

Thank you very much ! I agree it is very simple, it is likely the same on macOS. just create a GRContext and copy (are directly write to) in an ETO Bitmap.
But it looks event simpler on Windows, as I can see you’re not forced to create all that MTLQueue, MTLBuffer and so on :slight_smile:
Now I have to find a PC laptop to test it on windows…

I couldn’t find a way of directly writing to the bitmap from the GPU. The current version is relatively performant but it hits its limitations with increased amount of dropshadow effects. For your specific application, you could think of batching these calls and drawing them in one go. You could probably get away with three shadow passes, one for each ring:

Here is the latest, slightly more optimized version of my previous code. It runs on R8 .NET48 and .NET7

using System.Diagnostics;
using Eto.Forms;
using Eto.Drawing;
using SkiaSharp;
using Rhino.Commands;
using Rhino;

namespace SkiaSharpTest
{
    public class SkiaSharpTestCommand : Rhino.Commands.Command
    {
        public override string EnglishName => "SkiaSharpTestCommand";

        protected override Result RunCommand(RhinoDoc doc, RunMode mode)
        {
            var form = new MainDialog();
            form.ShowModal(Rhino.UI.RhinoEtoApp.MainWindow);
            return Result.Success;
        }
    }

    public class MainDialog : Dialog
    {
        public MainDialog()
        {
            Title = "SkiaSharp Animated Rings";
            ClientSize = new Size(1000, 1000);
            Location = new Point(0, 0);
            Content = new SkiaRingsDrawable();
        }
    }


    internal class SkiaRingsDrawable : Drawable
    {
        private Bitmap _etoBitmap;
        private SKImageInfo _info;
        private int _numLevels = 10;
        private float _centerX = 500;
        private float _centerY = 500;
        private float _baseInnerRadius = 10;
        private float _ringWidth = 40;
        private bool _drawShadow = true;
        private float _screenScale = 1.0f;

        private SKImageFilter _shadowFilter;

        private GRContext _grContext;

        // Animation variables
        private float _radiusOffset = 0f;
        private float _offsetIncrement = 0.2f;
        private float _maxOffset = 10f;
        private bool _expanding = true;

        private UITimer _timer;

        private SKPaint _fillPaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
        private SKPaint _shadowPaint;

        public SkiaRingsDrawable()
        {
            _etoBitmap = new Bitmap(1000, 1000, PixelFormat.Format32bppRgba);
            _info = new SKImageInfo(
                width: _etoBitmap.Width,
                height: _etoBitmap.Height,
                colorType: SKColorType.Bgra8888,
                alphaType: SKAlphaType.Premul
            );

            _grContext = GRContext.CreateGl();

            _shadowFilter = SKImageFilter.CreateDropShadowOnly(
                dx: 12 * _screenScale,
                dy: 12 * _screenScale,
                sigmaX: 8 * _screenScale,
                sigmaY: 8 * _screenScale,
                color: SKColors.Black.WithAlpha(120)
            );

            _shadowPaint = new SKPaint
            {
                ImageFilter = _shadowFilter,
                IsAntialias = true,
                Style = SKPaintStyle.Fill,
            };

            _timer = new UITimer();
            _timer.Interval = 0.016;
            _timer.Elapsed += (sender, e) =>
            {
                UpdateAnimation();
                DrawRings();
                Invalidate();
            };
            _timer.Start();

            this.UnLoad += (sender, e) =>
            {
                _timer.Stop();
                _shadowFilter?.Dispose();
                _grContext?.Dispose();
            };
        }

        private void UpdateAnimation()
        {
            if (_expanding)
            {
                _radiusOffset += _offsetIncrement;
                if (_radiusOffset >= _maxOffset)
                {
                    _radiusOffset = _maxOffset;
                    _expanding = false;
                }
            }
            else
            {
                _radiusOffset -= _offsetIncrement;
                if (_radiusOffset <= 0)
                {
                    _radiusOffset = 0;
                    _expanding = true;
                }
            }
        }

        private void DrawRings()
        {
            var stopwatch = Stopwatch.StartNew();

            using (var bitmapData = _etoBitmap.Lock())
            {
                // Create a hardware-accelerated surface
                using (var surface = SKSurface.Create(_grContext, false, _info))
                {
                    var canvas = surface.Canvas;
                    canvas.Clear(SKColors.White);

                    for (int level = 0; level < _numLevels; level++)
                    {
                        float animatedRingWidth = _ringWidth + _radiusOffset;
                        float innerRadius = _baseInnerRadius + level * animatedRingWidth;
                        float outerRadius = innerRadius + animatedRingWidth;

                        using (var path = new SKPath())
                        {
                            path.AddCircle(_centerX, _centerY, outerRadius);
                            path.AddCircle(_centerX, _centerY, innerRadius);
                            path.FillType = SKPathFillType.EvenOdd;

                            if (_drawShadow)
                                canvas.DrawPath(path, _shadowPaint);

                            _fillPaint.Color = (level % 2 == 0) ? SKColors.Blue : SKColors.Red;
                            canvas.DrawPath(path, _fillPaint);
                        }
                    }

                    canvas.Flush();
                    surface.ReadPixels(_info, bitmapData.Data, bitmapData.ScanWidth, 0, 0);
                }
            }

            stopwatch.Stop();
            Rhino.RhinoApp.WriteLine($"Frame render time: {stopwatch.Elapsed.TotalMilliseconds:F2} ms");
        }

        protected override void OnPaint(PaintEventArgs e)
        {
            base.OnPaint(e);
            e.Graphics.DrawImage(_etoBitmap, new PointF(0, 0));
        }
    }
}

You’re right, drawing shadow in one pass instead of 3 reduce rendering time from 15ms to ~3 ms.
It is logical since I draw once vs 3 times before
But I have a problem for managing level shadow opacity this way… Each level have its own opacity when appearing, so the “one pass” drawing is ugly :frowning:

Not sure I understand the issue. You could still have distinct opacities for each level of the menu with only three passes:

If I Draw a shadow for each level, I must do 3 passes (one for each level). It takes 15-20 ms, but in this case I can have différent opacity for each shadow.

If I Draw shadow in one pass (e.g for the whole menu, inclusing sub levels), it takes only 3-6ms, but in this case I cannot have opacity for each shadow.

What I mean by « a pass » is : one SKPath, one SKPaint. In 3 passes, three SKPath, three SKPaint.
The SKPaint is very time consuming.

And as I can have many sublevels animating (e.g one easing in, another easing out), sometime there are more than 3 shadow to Draw.

So in conclusion, using gpu still is the best way to get something animating smooth. And as you said, it is very simple to implement (simpler on Windows than macOS, I’ll have to check this)

:frowning: why only for mac? I really would love have this, this kind of tools should be integrated since R5.

Hi, for several reasons

  • rhino only provide eto c#, in my v1 I was forced to use OS specific APIs. Very difficult to port this on other OS than mac
  • In v2, I’m using 3D renderer that is also OS specific, but it should be easier to port on other OS. It’s just a matter of rendering engine
  • I don’t own a windows pc. So very difficult to work on Windows version

But, for now I’ve no time left to work on v2. I’m just an hobbyist :slight_smile: I don’t earn any money for this.

But you’re right, McNeel could implement such a tool.
As soon as I have free time, I’ll try to release a production version.

Damn I hope to see something like this fully integrated and customizable into Rhino. Even exist the Radial menu in grasshopper but can’t be add most used commands or can it?.
keep working you are doing very nice Job I hope you inspire the team of McNeel to develope/integrate this kind of widgets.

1 Like

Have you seen this (found on LinkedIn)?

1 Like