WebAssembly Browser App - slog(완료)

WebAssembly Browser App은 .NET 7에서 wasm-experimental 워크로드를 설치하면 사용할 수 있습니다.

WebAssembly Browser App 템플릿 프로젝트로 생성된 프로젝트 구조를 간단히 분석하면서 최종적으로 OffscreenCanvas를 이용한 그리기 모듈을 구현하는 목적으로 슬로그를 시작합니다.

2개의 좋아요

index.html

<!DOCTYPE html>
<!--  Licensed to the .NET Foundation under one or more agreements. -->
<!-- The .NET Foundation licenses this file to you under the MIT license. -->
<html>

<head>
  <title>WebAssemblyBrowserApp</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="modulepreload" href="./main.js" />
  <link rel="modulepreload" href="./dotnet.js" />
</head>

<body>
  <!--<span id="out"></span>-->
  <script type='module' src="./main.js"></script>
</body>

</html>

실행 결과를 출력 할 out span 태그가 보입니다. WebAssembly Browser App 관련 초기화 및 시작은 main.js에서 하게 됩니다.

1개의 좋아요

main.js

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { dotnet } from './dotnet.js'

const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);

const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
    .withDiagnosticTracing(false)
    .withApplicationArgumentsFromQuery()
    .create();

//setModuleImports("main.js", {
//    window: {
//        location: {
//            href: () => globalThis.window.location.href
//        }
//    }
//});

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
//const text = exports.MyClass.Greeting();
//console.log(text);

/*document.getElementById("out").innerHTML = `${text}`;*/
await runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);

dotnet.js에서 제공하는

  • setModuleImports : .NET 코드에서 사용할 수 있도록 import
  • getAssemblyExports : .NET의 코드를 실행할 수 있도록 export
  • getConfig : 설정 관련
  • runMainAndExit : .NET 메인 메소드 실행

의 기능을 이용하여 가장 간단한 기본 구성이 되어 있습니다.

1개의 좋아요

Program.cs

using System;

Console.WriteLine("Hello, Browser!");

//public partial class MyClass
//{
//    [JSExport]
//    internal static string Greeting()
//    {
//        var text = $"Hello, World! Greetings from {GetHRef()}";
//        Console.WriteLine(text);
//        return text;
//    }

//    [JSImport("window.location.href", "main.js")]
//    internal static partial string GetHRef();
//}

.NET 쪽 메인 함수입니다. 메인 함수는 실행되어 초기화 후 반환되어야 합니다.
main.jsrunMainAndExit()에 의해 호출되며 넘겨진 인자는 args에 담깁니다.

2개의 좋아요

OffscreenCanvas

Canvas의 그리기 성능을 더 높이기 위해 메인 스레드가 아닌 작업 스레드에서 그리기가 가능한 OffscreenCanvas 기능이 있습니다.

한글 자료로 아래의 글도 괜찮습니다.

Canvas에서 OffscreenCanvas를 획득 한 후 작업자 스레드에서 2d 컨텍스트를 얻어서 그리기를 하는 식입니다. 이렇게 되면 그리기 량이 많아진다 하더라도 웹브라우저의 동작성을 방해하지 않습니다.

2개의 좋아요

한계

HTML5 Canvas 객체 및 Context에 대한 .NET 래퍼 클래스를 제공해주진 않기 때문에 기능 하나하나 setModuleImports()로 노출해줘야만 합니다.

2개의 좋아요

스레드 사용

.NET으로 작업자 스레드를 어떻게 만들 수 있을까요? wasm-experimental웹 작업자를 사용해서 스레드를 쓸 수 있도록 합니다. 멀티 스레드를 활성화 하려면 프로젝트 설정에 다음을 추가합니다.

<PropertyGroup>
...
  <WasmEnableThreads>true</WasmEnableThreads>
</PropertyGroup>

이제 일반 애플리케이션에서 스레드를 사용하는 것 처럼 스레드를 사용할 수 있게 됩니다.

new Thread(SecondThread).Start();
Console.WriteLine($"Hello, Browser from the main thread {Thread.CurrentThread.ManagedThreadId}");

static void SecondThread()
{
    Console.WriteLine($"Hello from Thread {Thread.CurrentThread.ManagedThreadId}");
    for (int i = 0; i < 5; ++i)
    {
        Console.WriteLine($"Ping {i}");
        Thread.Sleep(1000);
    }
}

image

콘솔에 메인 스레드에서 차단되는 것은 위험하다는 경고 메시지가 발생하지만 실제로는 차단 없이 바로 init finished가 출력되는 것을 확인할 수 있습니다.

image

1개의 좋아요

import 및 export, JSMarshalAs 특성

import 및 export는 JSMarshalAs 특성을 통해 자동으로 마샬링 코드가 생성됩니다. 소스 생성기를 이용하므로 클래스 및 메서드에 partial 키워드를 사용해야 합니다.

  • import (가져오기)
    [JSImport("window.location.href", "main.js")]
    internal static partial string GetHRef();
  • export (내보내기)
    [JSExport]
    internal static string Greeting()
    {
        var text = $"Hello, World! Greetings from {GetHRef()}";
        Console.WriteLine(text);
        return text;
//    }

bool, byte, int, double(float) 및 string, 배열은 JSMarshalAs 특성을 사용하지 않아도 인식하고 마샬링합니다.

그러나 ushort, long(ulong), callback 함수(Action, Func) 등은 JsMarshalAs 특성을 정확히 잘 표현해야 합니다.

JSMarshalAsAttribute<TType>

JSMarshalAs 특성은 제네릭 인자로 JSType을 받습니다.

public sealed class JSMarshalAsAttribute<T> : Attribute where T : JSType

미리 정의된 JSType 타입은 다음과 같습니다.

    public sealed class Void : JSType {}
    public sealed class Discard : JSType {}
    public sealed class Boolean : JSType {}
    public sealed class Number : JSType {}
    public sealed class BigInt : JSType {}
    public sealed class Date : JSType {}
    public sealed class String : JSType {}
    public sealed class Object : JSType {}
    public sealed class Error : JSType {}
    public sealed class MemoryView : JSType {}
    public sealed class Array<T> : JSType where T : JSType {}
    public sealed class Promise<T> : JSType where T : JSType {}
    public sealed class Function : JSType {}
    public sealed class Function<T> : JSType where T : JSType {}
    public sealed class Function<T1, T2> : JSType where T1 : JSType where T2 : JSType {}
    public sealed class Function<T1, T2, T3> : JSType where T1 : JSType where T2 : JSType where T3 :  JSType {}
    public sealed class Function<T1, T2, T3, T4> : JSType where T1 : JSType where T2 : JSType where T3 : JSType where T4 : JSType {}
    public sealed class Any : JSType {}

다음음 배열의 경우 예시입니다.

        [JSImport("canvas.drawLineBrush", "main.js")]
        internal static partial void DrawLine(
          double x1,
          double y1,
          double x2,
          double y2,
          double strokeWidth,
          string color
           [JSMarshalAs<JSType.Array<JSType.Number>>] double[] lineDashes);

다음은 콜벡함수의 예시 입니다.

    [JSImport("requestAnimationFrame", "main.js")]
    internal static partial void requestAnimationFrame(
        [JSMarshalAs<JSType.Function>] Action callback);
1개의 좋아요

canvas 사용 예시

대략적인 모습입니다.

| main.js

setModuleImports("main.js", {
    canvas: {
        clear: (color) => {
            context.fillStyle = color;
            context.fillRect(0, 0, canvas.width, canvas.height);
        },
        setOpacity: (value) => {
            context.globalAlpha = value;
        },
        getOpacity: () => {
            return context.globalAlpha;
        },
        drawLine: (x1, y1, x2, y2, color) => {
            context.strokeStyle = color;
            context.beginPath();
            context.moveTo(x1, y1);
            context.lineTo(x2, y2);
            context.stroke();
        },
        drawLineBrush: (x1, y1, x2, y2, strokeWidth, color, lineDashes) => {
            context.lineWidth = strokeWidth;
            context.strokeStyle = color;
            context.setLineDash(lineDashes);
            context.beginPath();
            context.moveTo(x1, y1);
            context.lineTo(x2, y2);
            context.stroke();
        }
    }
    //    window: {
    //        location: {
    //            href: () => globalThis.window.location.href
    //        }
    //    }
});

| cs

    internal static partial class JSImport
    {
        [JSImport("canvas.clear", "main.js")]
        internal static partial void Clear(string color);

        [JSImport("canvas.setOpacity", "main.js")]
        internal static partial void SetOpacity(double value);

        [JSImport("canvas.getOpacity", "main.js")]
        internal static partial double GetOpacity();

        [JSImport("canvas.drawLine", "main.js")]
        internal static partial void DrawLine(double x1, double y1, double x2, double y2, string color);

        [JSImport("canvas.drawLineBrush", "main.js")]
        internal static partial void DrawLine(double x1, double y1, double x2, double y2, double strokeWidth, string color, [JSMarshalAs<JSType.Array<JSType.Number>>] double[] lineDashes);

        internal static string ToRgbString(Color color) => $"rgba({color.R}, {color.G}, {color.B}, {color.A})";
    }

구현 중…

이제 이런 코드로

var ds = new HtmlCanvasDrawningSession();


ds.Clear(new Color(0xAAAAAA));

//ds.Opacity = 1.0f;

for (var i = 0; i < 800; i += 10)
    ds.DrawLine(i, 0, i + 100, 100, new Color(0xff0000));


var brush = new CanvasBrush(new Color(0x00ff00), 5, new[] { 5d, 15d });

ds.DrawLine(200, 200, 300, 300, brush);

ds.DrawRectangle(300, 300, 150, 200, brush);

ds.DrawRectangle(400, 400, 200, 150, new Color(0x0000ff));

ds.FillRectangle(500, 500, 150, 100, new Color(0xff0000));

ds.DrawRoundedRectangle(100, 300, 100, 150, 10, 10, new Color(0xff0000));

ds.DrawRoundedRectangle(100, 500, 100, 150, 10, 10, brush);

ds.FillRoundedRectangle(300, 300, 100, 150, 10, 10, new Color(0x0000ff));

ds.FillRoundedRectangle(300, 500, 100, 150, 10, 10, brush);

ds.FillCircle(700, 300, 40, new Color(0x00ff00));

ds.DrawCircle(800, 300, 40, brush);

ds.FillArc(700, 400, 40, 0, 180, new Color(0x00ff00));

ds.DrawArc(800, 400, 40, 0, 180, brush);

ds.DrawText("Test Text! 한글!", 300, 150, new Color(0xFF0000), new CanvasTextFormat
{
    FontSize = 24,
});

ds.DrawText("Test Text! 한글!", 500, 150, 120, 120, new Color(0xFF0000), new CanvasTextFormat
{
    FontSize = 24,
});


var size = ds.MeasureTextSize("Test Text! 한글!", new CanvasTextFormat
{
    FontSize = 24,
});

ds.DrawRectangle(500, 150, size.Width, size.Height, new Color(0x00FF00));

Console.WriteLine($"MeasureTextSize => {size.Width}, {size.Height}");

다음의 화면을 웹페이지로 만들 수 있습니다.

image

1개의 좋아요

인터페이스에 맞춰서 Canvas로 그리기 기능을 잘 구현했다면 인터페이스 기준으로 기존에 잘 만든 기능을 이용해 웹브라우저에서도 동작성을 확인할 수 있게 됩니다.

image

4개의 좋아요

비단 Canvas 뿐만 아니라 SVG로도 인터페이스만 착실히 맞춰준다면 SVG로의 전환도 문제 없습니다.

image

SVG는 적절하게 SvgElement → SvgContainer → SvgRoot, SvgGroup 으로 확장 구현하고
SvgElement는 다음 처럼 최종 SVG XML을 생성하도록 구성합니다.

| SvgElement

internal abstract class SvgElement
{
    public abstract string Tag { get; }
    public SvgElement? Content { get; set; }
    public Color? StrokeColor { get; set; }
    public double StrokeWidth { get; set; } = 1;
    public double[]? StrokeDashArray { get; set; }
    public Color? FillColor { get; set; }


    protected virtual void AddProperties(StringBuilder sb)
    {
        if (StrokeColor is not null)
            AddProperty(sb, "stroke", StrokeColor?.ToRgbaString());

        AddProperty(sb, "stroke-width", StrokeWidth);

        if (StrokeDashArray is not null)
        {
            AddProperty(sb, "stroke-dasharray", string.Join(' ', StrokeDashArray));
        }

        if (FillColor is not null)
            AddProperty(sb, "fill", FillColor?.ToRgbaString());
    }

    protected virtual bool HaveStyles() => false;

    protected virtual void AddStyles(StringBuilder sb)
    {
    }

    protected static void AddStyle(StringBuilder sb, string styleName, object? value)
    {
        if (value is null)
            return;

        sb.Append(styleName);
        sb.Append(':');
        sb.Append(value.ToString());
        sb.Append(';');
    }

    protected virtual void AddContent(StringBuilder sb)
    {
    }

    protected static void AddProperty(StringBuilder sb, string propertyName, object? value)
    {
        if (value is null)
            return;

        sb.Append(' ');
        sb.Append(propertyName);
        sb.Append('=');
        sb.Append($"\"{value}\"");
    }

    public override string ToString()
    {
        var sb = new StringBuilder();
        ToString(sb);
        return sb.ToString();
    }

    public virtual void ToString(StringBuilder sb)
    {
        sb.Append('<');
        sb.Append(Tag);
        AddProperties(sb);
        if (HaveStyles() is true)
        {
            sb.Append("style=\"=");
            AddStyles(sb);
            sb.Append('"');
        }
        sb.Append('>');

        AddContent(sb);

        sb.Append("</");
        sb.Append(Tag);
        sb.Append('>');
    }
}

이제 이 규칙에 맞게 SVG 엘리먼트를 클래스로 구성하면 되는 것이지요.

1개의 좋아요

참고로 파워포인트에 image/svg+xml로 클립보드로 SVG를 넣어두면 붙여넣기 할 때 벡터 형태도 파워포인트로 붙여넣기 되는것을 알 수 있습니다. 실제로 파워포인트의 그리기 개체를 선택 클립보드로 복사할 때도 image/svg+xml 형태를 제공해줘서 SVG를 해석할 수 있는 어플리케이션이라면 파워포인트 그리기 개체를 이미지가 아닌 벡터 단위로 사용할 수 있습니다.

image

2개의 좋아요