WebAssembly Browser App은 .NET 7에서 wasm-experimental 워크로드를 설치하면 사용할 수 있습니다.
WebAssembly Browser App 템플릿 프로젝트로 생성된 프로젝트 구조를 간단히 분석하면서 최종적으로 OffscreenCanvas를 이용한 그리기 모듈을 구현하는 목적으로 슬로그를 시작합니다.
WebAssembly Browser App은 .NET 7에서 wasm-experimental 워크로드를 설치하면 사용할 수 있습니다.
WebAssembly Browser App 템플릿 프로젝트로 생성된 프로젝트 구조를 간단히 분석하면서 최종적으로 OffscreenCanvas를 이용한 그리기 모듈을 구현하는 목적으로 슬로그를 시작합니다.
<!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에서 하게 됩니다.
// 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 코드에서 사용할 수 있도록 importgetAssemblyExports : .NET의 코드를 실행할 수 있도록 exportgetConfig : 설정 관련runMainAndExit : .NET 메인 메소드 실행의 기능을 이용하여 가장 간단한 기본 구성이 되어 있습니다.
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.js의 runMainAndExit()에 의해 호출되며 넘겨진 인자는 args에 담깁니다.
Canvas의 그리기 성능을 더 높이기 위해 메인 스레드가 아닌 작업 스레드에서 그리기가 가능한 OffscreenCanvas 기능이 있습니다.
한글 자료로 아래의 글도 괜찮습니다.
Canvas에서 OffscreenCanvas를 획득 한 후 작업자 스레드에서 2d 컨텍스트를 얻어서 그리기를 하는 식입니다. 이렇게 되면 그리기 량이 많아진다 하더라도 웹브라우저의 동작성을 방해하지 않습니다.
.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);
}
}

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

import 및 export는 JSMarshalAs 특성을 통해 자동으로 마샬링 코드가 생성됩니다. 소스 생성기를 이용하므로 클래스 및 메서드에 partial 키워드를 사용해야 합니다.
[JSImport("window.location.href", "main.js")]
internal static partial string GetHRef();
[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);
대략적인 모습입니다.
| 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}");
다음의 화면을 웹페이지로 만들 수 있습니다.

인터페이스에 맞춰서 Canvas로 그리기 기능을 잘 구현했다면 인터페이스 기준으로 기존에 잘 만든 기능을 이용해 웹브라우저에서도 동작성을 확인할 수 있게 됩니다.
비단 Canvas 뿐만 아니라 SVG로도 인터페이스만 착실히 맞춰준다면 SVG로의 전환도 문제 없습니다.

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