⚠️ Warning: This website is still being developed. Code examples, videos, and links may be broken while I continue to work on it. -- Scott, May 10, 2020
Resources for visualizing data using C# and the .NET platform

Creating and Injecting Abstract Renderers

In this article we will refactor the constellation project to use a renderer interface, allowing us to consolidate rendering into a single Render method inside the graphics model. In this design graphics-library-specific renderers are created in the target application and passed into the graphics model's render method. In this way we consolidate graphics modeling and rendering logic inside a single graphics model library which has no dependencies, allowing it to be used on any platform and with any graphics library.

System.Drawing SkiaSharp with OpenGL

Graphics Model

Create Dependency-Free Drawing Objects

We want to command all drawing from our graphics model library, but we don't want this library to have any dependencies which may tie it to a specific framework. Therefore, instead of using System.Drawing's Point or SkiaSharp's SKPoint, we will create our own Point class (and similar objects). Later we will write library-specific helper methods to convert our graphics model's point, color, etc. to those of the target library.

public class Color
{
    public readonly byte R, G, B, A;
    public Color(byte red, byte green, byte blue, byte alpha = 255)
    {
        (R, G, B, A) = (red, green, blue, alpha);
    }

    public Color(string hex, byte alpha = 255)
    {
        if ((hex.Length != 7) || (hex.Substring(0, 1) != "#"))
            throw new ArgumentException("invalid hex color string");

        R = Convert.ToByte(hex.Substring(1, 2), 16);
        G = Convert.ToByte(hex.Substring(3, 2), 16);
        B = Convert.ToByte(hex.Substring(5, 2), 16);
        A = alpha;
    }
}
public class Point
{
    public readonly double X, Y;
    public Point(double x, double y)
    {
        (X, Y) = (x, y);
    }
}

Create a Renderer Interface IRenderer

We can add functionality as our application demands. The starfield only requires these 3 drawing methods, so let's keep it simple. All objects in arguments are defined in this project (dependency-free).

    public interface IRenderer : IDisposable
{
    void Clear(Color color);
    void DrawLine(Point pt1, Point pt2, double lineWidth, Color color);
    void FillCircle(Point center, double radius, Color color);
}

Add Graphics Details to the Star Class

Previously our Star class was intentionally devoid of graphics information like color or radius because that would require coupling our graphics model to a specific rendering library.

Since we are now defining all graphics properties using dependency-free objects in this library, we can add these useful properties to our Star class

public class Star
{
    public float X;
    public float Y;
    public float Xvel;
    public float Yvel;

    public float Radius = 3;
    public Color Color = new Color(255, 255, 255);
    public Point Point { get { return new Point(X, Y); } }
}

Add a Render Method to the Field Class

Previously we always rendered our field using a method in our target application filled with graphics-library-specific calls. Since we've abstracted the renderer in this design, we can add the render method inside our Field class. Since it accepts an IRenderer, it can use its methods to perform the drawing tasks without requiring implementation details to be known at compile time.

public void Render(IRenderer renderer)
{
    // clear background
    var backColor = new Color("#003366");
    renderer.Clear(backColor);

    // draw stars
    foreach (var star in Stars)
        renderer.FillCircle(star.Point, star.Radius, star.Color);

    // draw lines connecting close stars
    double connectDistance = 100;
    foreach (var star1 in Stars)
    {
        foreach (var star2 in Stars)
        {
            // determine star distance
            double dX = Math.Abs(star1.X - star2.X);
            double dY = Math.Abs(star1.Y - star2.Y);
            if (dX > connectDistance || dY > connectDistance)
                continue;
            double distance = Math.Sqrt(dX * dX + dY * dY);

            // set line alpha based on distance
            int alpha = (int)(255 - distance / connectDistance * 255);
            alpha = Math.Min(alpha, 255);
            alpha = Math.Max(alpha, 0);
            var lineColor = new Color(255, 255, 255, (byte)alpha);
            if (distance < connectDistance)
                renderer.DrawLine(star1.Point, star2.Point, 1, lineColor);
        }
    }
}

Create Library-Specific Renderers

System.Drawing Renderer

We will now create a renderer which implements IRenderer to use in applications where System.Drawing will be used to draw graphics.

class SystemDrawingRenderer : IRenderer
{
    private readonly System.Drawing.Graphics Gfx;

    // modify a pen and brush rather than frequently create/destroy them
    private System.Drawing.Pen Pen = new Pen(System.Drawing.Color.White);
    private System.Drawing.SolidBrush Brush = new SolidBrush(System.Drawing.Color.Black);

    public SystemDrawingRenderer(System.Drawing.Bitmap bmp)
    {
        Gfx = System.Drawing.Graphics.FromImage(bmp);
        Gfx.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
    }

    public void Dispose()
    {
        Pen.Dispose();
        Brush.Dispose();
        Gfx.Dispose();
    }

    // helper function to convert a Point
    private System.Drawing.PointF Convert(GraphicsModel.Point pt)
    {
        return new PointF((float)pt.X, (float)pt.Y);
    }

    // helper function to convert a Color
    private System.Drawing.Color Convert(GraphicsModel.Color color)
    {
        return System.Drawing.Color.FromArgb(color.A, color.R, color.G, color.B);
    }

    // helper function to convert a Circle to a Rectangle
    private System.Drawing.RectangleF Circle(GraphicsModel.Point center, double radius)
    {
        return new RectangleF(
            x: (float)(center.X - radius),
            y: (float)(center.Y - radius),
            width: (float)(radius * 2),
            height: (float)(radius * 2));
    }

    public void Clear(GraphicsModel.Color color)
    {
        Gfx.Clear(Convert(color));
    }

    public void DrawLine(GraphicsModel.Point pt1, GraphicsModel.Point pt2, double lineWidth, GraphicsModel.Color color)
    {
        Pen.Width = (float)lineWidth;
        Pen.Color = Convert(color);
        Gfx.DrawLine(Pen, Convert(pt1), Convert(pt2));
    }

    public void FillCircle(GraphicsModel.Point center, double radius, GraphicsModel.Color color)
    {
        Brush.Color = Convert(color);
        Gfx.FillEllipse(Brush, Circle(center, radius));
    }
}

SkiaSharp Renderer

We will now create a renderer which implements IRenderer to use in applications where SkiaSharp will be used to draw graphics.

class SkiaSharpRenderer : IRenderer
{
    readonly SKCanvas Canvas;

    // modify a SKPaint rather than frequently create/destroy one
    private SKPaint Paint;

    public SkiaSharpRenderer(SKCanvas canvas)
    {
        Canvas = canvas;
        Paint = new SKPaint() { IsAntialias = true };
    }

    public void Dispose()
    {
        Paint.Dispose();
    }

    // helper function to convert a Point
    private SKPoint Convert(GraphicsModel.Point pt)
    {
        return new SKPoint((float)pt.X, (float)pt.Y);
    }

    // helper function to convert a Color
    private SKColor Convert(GraphicsModel.Color color)
    {
        return new SKColor(color.R, color.G, color.B, color.A);
    }

    public void Clear(Color color)
    {
        Canvas.Clear(Convert(color));
    }

    public void DrawLine(Point pt1, Point pt2, double lineWidth, Color color)
    {
        Paint.Color = Convert(color);
        Canvas.DrawLine(Convert(pt1), Convert(pt2), Paint);
    }

    public void FillCircle(Point center, double radius, Color color)
    {
        Paint.Color = Convert(color);
        Canvas.DrawCircle(Convert(center), (float)radius, Paint);
    }
}

Initiate Renders in the GUI

Now that all the rendering tasks are performed in our graphics model (with library-specific calls translated by our custom renderer), the GUI code becomes refreshingly simple.

System.Drawing

private void timer1_Tick(object sender, EventArgs e)
{
    using (var bmp = new Bitmap(pictureBox1.Width, pictureBox1.Height, PixelFormat.Format32bppPArgb))
    using (var renderer = new SystemDrawingRenderer(bmp))
    {
        field.Render(renderer);
        pictureBox1.Image?.Dispose();
        pictureBox1.Image = (Bitmap)bmp.Clone();
    }
}

SkiaSharp

private void skglControl1_PaintSurface(object sender, SkiaSharp.Views.Desktop.SKPaintGLSurfaceEventArgs e)
{
    using (var renderer = new SkiaSharpRenderer(e.Surface.Canvas))
        field.Render(renderer);
}

Conclusions

This new design is much easier to maintain now that the render function lives inside the graphics model. Since an abstracted renderer is created externally and injected into the graphics model's render method, this graphics model does not have to be modified to support new graphics libraries.

Let's consider how this design relates to the SOLID principles:

  • Single Responsibility Principle: The graphics model module completely handles model updates and rendering tasks. No GUI code written will have to know what a star is or ever draw a line, leaving the GUI code can focus solely on GUI tasks.

  • Open-Close Principle: This module is open for extension in that we can add new rendering methods like DrawArc() and FillRectangle() without modifying functionality of the existing methods. In fact, existing public methods can be regarded as permanently closed for modification. DrawLine() will always draw a line, and this will never change even as new graphics libraries are invented in the future.

  • Liskov Substitution Principle: Does not apply because this example does not use inheritance.

  • Interface Segregation Principle: Does not apply because this example would not benefit from multiple interfaces.

  • Dependency Inversion Principle: This is the greatest strength of the latest refactoring. The rendering method (and the entire graphics model) is totally free of dependencies. The target application will have platform-specific and graphics-library-specific dependencies, but the graphics model and it's render method do not care about these details. This is an excellent flow of control: high-level modules (the graphics model) do not care about the low-level modules (implementation details like Windows Forms vs. WPF, or System.Drawing. vs. SkiaSharp).

Source Code

GitHub: /examples/drawing/constellation/injected