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.

1 Like

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)?

2 Likes

I’ve been trying to remove the Eto background, but I’ve been completely unsuccessful. What’s the process for doing this with Eto Rhino windows?

Hi @TheLionMad,

The new method in Rhino 8.21+ is to set the Eto.Form background color to a 0 alpha value, previously this resulted in a “translucent” looking form but should now allow full Transparency.

In Python it would look something like this (where self is within your Form’s init code):

self.BackgroundColor = Eto.Drawing.Color.FromArgb(0,0,0,0)

or

self.BackgroundColor = Eto.Drawing.Colors.Transparent

Hope that helps!

2 Likes

Hi, I’m working on a windows version of my macOS radialmenu. It’s a real head hake… :frowning:

On windows it seems sooooo difficult to have a transparent form that:

  1. Is fully transparent, i.e. Let’s mouse events pass through (moves, click, so rhino window can handle them)
  2. Can be a drop target (i.e. If I “move” a rhino toolbar item to the transparent form, I want to be able to have a drag enter event,a nd so on….)
  3. Drawing inside this form a GPU accelerated shape (i.e. a radialmenu)

This sounds “simple”, but I spent hours with many windows tricks and many “interop” things (like D3D surface, Vorice and so on…), but no result at all. Either I get full transparency but loose drag support, or I get drag support without transparency.

If someone have some background on this, I’ll be pleased for him to share its techniques.

I also add that setting AllowsTransparency=True on a windows form does not the trick because in this case the form is fully transparent but then will never respond to any drag item.

On macOS, no problem, it’s already done and fully working.

3 Likes

You now what guys ? I’m almost done with windows :partying_face:

For those interested I give the overall strategy:

  1. Custom ETO control (see it at Custom Platform Controls · picoe/Eto Wiki · GitHub) so I can have a native renderer for macOS and windows
    1. For windows I choose System.Windows.Controls.UserControl, maybe not the best choice but it works great
  2. The hardest part on windows:
    1. Full transparent WPF form (simple : Setting “AllowsTransparency” is enough)
    2. The render pipeline
      1. OpenGL with FBO → Rendering “offscreen” (in a window) → Shared FBO with D3D9 texture → D3D11 device → Draw D3D11 image into wpf form native control
      2. This one was very hard to find, fighting with transparency, mouse events, drag events and so on. Just the POC is about 1000 lines of codes. There is lot of P/Invoke, dll imports etc.

So now, I have done a POC of a Rhino 8 plugin that show an amazing blue circle :slight_smile: But now the wpf form can “float” above rhino, stay on top, render with gpu acceleration, and we can drag/drop things on it.

I spent a lot of hours (days, nights) on this. I think, I’ll can begin implement the real radialmenu this week. The first release will take some months because I have to refactor a lot of things.

I’m sorry, but with all the time spent on this, I’m wondering if I’ll request a little money for this. I think I spent more than 6 months, and the work is not done yet.

Anyway, I have to implement skia drawing with openGL. I hope the native control for macOS will be simpler than the one on windows.

Ho, I forget : Thanks to “Claude” and “ChatGPT” for their help :wink:

10 Likes

Don’t give up @Tigrou!

Ultimately, you’ve learned tons in the process and this experience will stay with you forever.
One thing to keep in mind - Rhino 9 on Windows will apparently be moving away from OpenGL and adopting Dx3D instead.

Something to keep in mind if you’re investing a lot of effort into low-level hacks into the displaypipeline.

1 Like