Blazor Webassembly + Canvas로 눈내리는 것을 만들어봤습니다

Blazor Webassembly에서 BlazorExtensions/Canvas를 이용하면 HTML5/Canvas를 사용할 수 있습니다.

앞전에 소개한 Blazor and 2D game development – part 1: intro - David Guida 를 이용해서 눈 내리는 데모를 만들어 보았습니다.

image

좋아요 1

AMD 4800HS일 때 43.5 fps, Release 모드일 때도 큰 차이가 없는것으로 보아 async Javascript interop의 문제인 듯

InvokeUnmarshalled을 이용하면 Javascript interop을 통해 인자가 JSON으로 직렬화 하지 않고, 바로바로 전달 할 수 있어서 빠르다고 합니다. 얼마나 빠른가 너무 궁금해져서 InvokeUnmarshalled버젼을 구현하게 되었습니다.

진행하다 보니 생각보다 잔일이 있네요. 호출하는 canvas 함수의 인자를 변환 처리해야 하기 때문에 하나하나 구현해야 합니다.

                clearRect: function (fields) {
                    const c = window.game.context;
                    const x = Blazor.platform.readInt32Field(fields, 0);
                    const y = Blazor.platform.readInt32Field(fields, 4);
                    const width = Blazor.platform.readInt32Field(fields, 8);
                    const height = Blazor.platform.readInt32Field(fields, 12);
                    c.clearRect(x, y, width, height);
                },
                strokeText: function (fields) {
                    const c = window.game.context;
                    const text = Blazor.platform.readStringField(fields, 0);
                    const x = Blazor.platform.readInt32Field(fields, 8);
                    const y = Blazor.platform.readInt32Field(fields, 12);
                    c.strokeText(text, x, y);
                },
                beginPath: function () {
                    const c = window.game.context;
                    c.beginPath();
                },
                //await cc.ArcAsync(snow.X, snow.Y, snow.Size, 0, 2 * Math.PI, true);
                arc: function (fields) {
                    const c = window.game.context;
                    const x = Blazor.platform.readFloatField(fields, 0);
                    const y = Blazor.platform.readFloatField(fields, 4);
                    const radius = Blazor.platform.readFloatField(fields, 8);
                    const startAngle = Blazor.platform.readFloatField(fields, 12);
                    const endAngle = Blazor.platform.readFloatField(fields, 16);
                    const anticlockwise = Blazor.platform.readInt32Field(fields, 20);
                    c.arc(x, y, radius, startAngle, endAngle, anticlockwise);
                },
                setFillStyle: function(pFields) {
                    const c = window.game.context;
                    const fields = Blazor.platform.readStringField(pFields, 0);
                    c.fillStyle = fields;
                },
                fill: function () {
                    const c = window.game.context;
                    c.fill();
                }

그리고 C#쪽으로 코드도 InvokeUnmarshalled을 쓰는 것으로 바꾸었습니다.

    [JSInvokable]
    public void GameLoop2(float timeStamp, int width, int height)
    {
        var fps = 1000d / (sw.ElapsedMilliseconds - lastMs);
        lastMs = sw.ElapsedMilliseconds;

        if (snowList.Count < MaxSnow)
        {
            snowList.Add(new()
            {
                X = rand.Next(0, width),
                Y = -rand.Next(0, height),
                Kind = snowList.Count / (MaxSnow / 5)
            });
        }

        //await cc.BeginBatchAsync();
        //await cc.ClearRectAsync(0, 0, width, height);
        JSUnmarshalledRuntime.InvokeUnmarshalled<(int, int, int, int), bool>("game.clearRect", (0, 0, width, height));

        //await cc.StrokeTextAsync($"count: {fpsCount}, {fps:00.0}", 16, 16);
        JSUnmarshalledRuntime.InvokeUnmarshalled<(string, int, int), bool>("game.strokeText", ($"count: {fpsCount}, {fps:00.0}", 16, 16));

        foreach (var snow in snowList)
        {
            //await cc.BeginPathAsync();
            JSUnmarshalledRuntime.InvokeUnmarshalled<bool>("game.beginPath");
            //await cc.ArcAsync(snow.X, snow.Y, snow.Size, 0, 2 * Math.PI, true);
            JSUnmarshalledRuntime.InvokeUnmarshalled<(float, float, float, float, float, bool), bool>("game.arc", (snow.X, snow.Y, snow.Size, 0f, (float)(2 * Math.PI), true));
            //await cc.SetFillStyleAsync(snow.Color);
            JSUnmarshalledRuntime.InvokeUnmarshalled<(string, bool), bool>("game.setFillStyle", (snow.Color, true));
            //await cc.FillAsync();
            JSUnmarshalledRuntime.InvokeUnmarshalled<bool>("game.fill");

            snow.Y += snow.Kind switch { 0 => 1, 1 => 2, 2 => 3, 3 => 4, 4 => 5, _ => 5 };
            if (snow.Y > height)
                snow.Y = 0;

            snow.X += rand.Next(0, 2);
            if (snow.X > width)
                snow.X = 0;
        }

        //await cc.EndBatchAsync();

        fpsCount++;
    }

그리고 돌리자, 아주 놀라운 일이 일어났습니다. 앞전의 fps가 43.5 fps 였는데요, 이제 250 fps가 나옵니다.
아무것도 안하는 requestAnimationFrame호출도 250 fps가 나온것을 보았을 때,
여기서 250은 엣지에서 requestAnimationFrame의 최대 인 것으로 추측이 됩니다.

오 디스플레이 새로고침 빈도일 가능성이 높군요

https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame

실행 데모입니다.

C# Snow Blazor (maum.in)

눈을 500개로 늘리고 InvokeAsync vs InvokeUnmarshalled를 비교해보니 7 fps vs 100 fps 100 차이가 나네요. 대략 10배 이상의 성능 차이입니다.

이제 여기서 생각해 볼 내용이, 비동기 호출을 하는 이유는 Blazor Server와의 호환성 때문입니다. 왜냐하면 서버의 작업은 동기보다는 비동기가 효과적으로 동작하기 때문인데요, 덕분에 Webassembly에서 Javascript의 호출 성능하락의 원인이됩니다. 또한 인자를 넘길 때 JSON 직렬화를 한다고 하는데요, 이부분에서 상당한 성능하락이 발생하나봅니다.

결론은, Webassembly 전용 프로그램을 Blazor로 개발할 경우 동기호출을 쓰거나, InvokeUnmarshalled를 쓰는게 좋다. 단, InvokeUnmarshalled방식은 문서화가 아직 되지 않고 언제든지 메소드명이 바뀌거나 없어질 수 있다고 하니, 조심스럽게 사용해야 하겠습니다.

좋아요 1