SWHarden.com

The personal website of Scott W Harden

Draw Animated Graphics in the Browser with Blazor WebAssembly

Client-side Blazor allows C# graphics models to be rendered in the browser. This means .NET developers can create web apps with business logic written (and tested) in C# instead of being forced to write their business logic in a totally different language (JavaScript) just to get it to run in the browser. In this project we’ll create an interactive graphics model (a field of balls that bounce off the edge of the screen) entirely in C#, and draw the model on the screen using an API that allows us to interact with a HTML5 Canvas.

Strategy

At the time of this writing Blazor WebAssembly can’t directly paint on the screen, so JavaScript is required somewhere to make this happen. The Blazor.Extensions.Canvas package has a Canvas component and provides a C# API for all of its JavaScript methods, allowing you to draw on the canvas entirely from C#.

Rendering gets a little slower every time a JavaScript function is called from Blazor. If a large number of shapes are required (a lot of JavaScript calls), performance may be limited compared to a similar application written entirely in JavaScript. For high performance rendering of many objects, a rendering loop written entirely in JavaScript may be required.

This method is good for simple models with a limited number of shapes. It allows the business logic (and tests) to remain entirely in C#. The same graphics model code could be displayed in the browser (using HTML canvas) or on the desktop in WPF and WinForms apps (using System.Drawing or SkiaSharp).

Step 1: Create a Pure C# Graphics Model

I’m using graphics model to describe the state of the field of balls and logic required to move each ball around. Ideally this logic would be isolated in a separate library. At the time of writing a .NET Standard C# library seems like a good idea, so the same graphics model could be used in .NET Framework and .NET Core environments.

Models/Ball.cs

public class Ball
{
    public double X { get; private set; }
    public double Y { get; private set; }
    public double XVel { get; private set; }
    public double YVel { get; private set; }
    public double Radius { get; private set; }
    public string Color { get; private set; }

    public Ball(double x, double y, double xVel, double yVel, double radius, string color)
    {
        (X, Y, XVel, YVel, R, Color) = (x, y, xVel, yVel, radius, color);
    }

    public void StepForward(double width, double height)
    {
        X += XVel;
        Y += YVel;
        if (X < 0 || X > width)
            XVel *= -1;
        if (Y < 0 || Y > height)
            YVel *= -1;

        if (X < 0)
            X += 0 - X;
        else if (X > width)
            X -= X - width;

        if (Y < 0)
            Y += 0 - Y;
        if (Y > height)
            Y -= Y - height;
    }
}

Models/Field.cs

public class Field
{
    public readonly List<Ball> Balls = new List<Ball>();
    public double Width { get; private set; }
    public double Height { get; private set; }

    public void Resize(double width, double height) =>
        (Width, Height) = (width, height);

    public void StepForward()
    {
        foreach (Ball ball in Balls)
            ball.StepForward(Width, Height);
    }

    private double RandomVelocity(Random rand, double min, double max)
    {
        double v = min + (max - min) * rand.NextDouble();
        if (rand.NextDouble() > .5)
            v *= -1;
        return v;
    }


    private string RandomColor(Random rand) => 
	    string.Format("#{0:X6}", rand.Next(0xFFFFFF));
	
    public void AddRandomBalls(int count = 10)
    {
        double minSpeed = .5;
        double maxSpeed = 5;
        double radius = 10;
        Random rand = new Random();

        for (int i = 0; i < count; i++)
        {
            Balls.Add(
                new Ball(
                    x: Width * rand.NextDouble(),
                    y: Height * rand.NextDouble(),
                    xVel: RandomVelocity(rand, minSpeed, maxSpeed),
                    yVel: RandomVelocity(rand, minSpeed, maxSpeed),
                    radius: radius,
                    color: RandomColor(rand);
                )
            );
        }
    }
}

Step 2: Get the Blazor.Extensions.Canvas Package

Use NuGet to install Blazor.Extensions.Canvas

Install-Package Blazor.Extensions.Canvas

Step 3: Add a script to index.html

This JavaScript sets-up the render loop which automatically calls RenderInBlazor C# method on every frame. It also calls the ResizeInBlazor C# function whenever the canvas is resized so the graphics model’s dimensions can be updated. You can place it just before the closing </body> tag.

This code will automatically resize the canvas and graphics model to fit the window, but for a fixed-size canvas you can omit the addEventListener and resizeCanvasToFitWindow calls.

<script src='_content/Blazor.Extensions.Canvas/blazor.extensions.canvas.js'></script>
<script>
    function renderJS(timeStamp) {
        theInstance.invokeMethodAsync('RenderInBlazor', timeStamp);
        window.requestAnimationFrame(renderJS);
    }

    function resizeCanvasToFitWindow() {
        var holder = document.getElementById('canvasHolder');
        var canvas = holder.querySelector('canvas');
        if (canvas) {
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            theInstance.invokeMethodAsync('ResizeInBlazor', canvas.width, canvas.height);
        }
    }

    window.initRenderJS = (instance) => {
        window.theInstance = instance;
        window.addEventListener("resize", resizeCanvasToFitWindow);
        resizeCanvasToFitWindow();
        window.requestAnimationFrame(renderJS);
    };
</script>

Step 4: Create a page for the Canvas and Render code

This step ties everything together:

For simplicity it’s demonstrated here using a code-behind, but a clearer strategy would be to move the render logic into its own class/file.

@page "/"

@using Blazor.Extensions
@using Blazor.Extensions.Canvas
@using Blazor.Extensions.Canvas.Canvas2D
@inject IJSRuntime JsRuntime;

<div id="canvasHolder" style="position: fixed; width: 100%; height: 100%">
    <BECanvas Width="600" Height="400" @ref="CanvasRef"></BECanvas>
</div>

@code{
    private Models.Field BallField = new Models.Field();
    private Canvas2DContext ctx;
    protected BECanvasComponent CanvasRef;
    private DateTime LastRender;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        this.ctx = await CanvasRef.CreateCanvas2DAsync();
        await JsRuntime.InvokeAsync<object>("initRenderJS", DotNetObjectReference.Create(this));
        await base.OnInitializedAsync();
    }

    [JSInvokable]
    public void ResizeInBlazor(double width, double height) => BallField.Resize(width, height);

    [JSInvokable]
    public async ValueTask RenderInBlazor(float timeStamp)
    {
        if (BallField.Balls.Count == 0)
            BallField.AddRandomBalls(50);
        BallField.StepForward();

        double fps = 1.0 / (DateTime.Now - LastRender).TotalSeconds;
        LastRender = DateTime.Now;

        await this.ctx.BeginBatchAsync();
        await this.ctx.ClearRectAsync(0, 0, BallField.Width, BallField.Height);
        await this.ctx.SetFillStyleAsync("#003366");
        await this.ctx.FillRectAsync(0, 0, BallField.Width, BallField.Height);
        await this.ctx.SetFontAsync("26px Segoe UI");
        await this.ctx.SetFillStyleAsync("#FFFFFF");
        await this.ctx.FillTextAsync("Blazor WebAssembly + HTML Canvas", 10, 30);
        await this.ctx.SetFontAsync("16px consolas");
        await this.ctx.FillTextAsync($"FPS: {fps:0.000}", 10, 50);
        await this.ctx.SetStrokeStyleAsync("#FFFFFF");
        foreach (var ball in BallField.Balls)
        {
            await this.ctx.BeginPathAsync();
            await this.ctx.ArcAsync(ball.X, ball.Y, ball.Radius, 0, 2 * Math.PI, false);
            await this.ctx.SetFillStyleAsync(ball.Color);
            await this.ctx.FillAsync();
            await this.ctx.StrokeAsync();
        }
        await this.ctx.EndBatchAsync();
    }
}

Notice how the JavaScript calls are wrapped in BeginBatchAsync and EndBatchAsync. This allows the calls between these two statements to be bundled into a single call. Since Blazor/JavaScript interop calls are the primary bottleneck in this system, limiting the number of individual JavaScript calls has a large influence on final performance.

Notes and References

💡 I observed a strong performance increase when upgrading from .NET Core 3.1 to .NET 5. If you can, Migrate your Blazor project to .NET 5 or newer