Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Quill/llms.txt

Use this file to discover all available pages before exploring further.

The WebAssembly backend makes Prowl.Quill run entirely in the browser. The .NET WASM runtime executes the Quill geometry tessellation on the CPU side, then serialises the resulting vertex and draw-call data across the JavaScript boundary to a thin WebGL 2 renderer written in JS. Frame callbacks are exported via [JSExport] attributes and called from a requestAnimationFrame loop on the JavaScript side. Resources such as fonts, textures, and SVG files are embedded as assembly resources rather than loaded from the file system, since WASM has no traditional filesystem access.

Project Setup

The WASM example is a .NET WASM project (not Blazor — it is a bare dotnet-sdk WASM app). The key project file settings are:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Assets (fonts, textures, SVGs) should be added as <EmbeddedResource> items so they are included in the WASM bundle.

Entry Point and [JSExport] Callbacks

The static Main method is empty — actual initialisation happens in Init, which the JavaScript loader calls after all WASM module imports are ready. Each interactive event (frame, mouse, keyboard) has a corresponding [JSExport] method:
static void Main()
{
    // Entry point — actual init happens in Init() called from JS after module imports are ready.
}

[JSExport]
internal static void Init()
{
    WebGLInterop.InitWebGL("canvas");

    _renderer = new WebGLCanvasRenderer();
    var (cw, ch) = _renderer.GetCanvasSize();
    _canvas = new Canvas(_renderer, new FontAtlasSettings());

    // Load fonts from embedded resources
    var robotoFont = LoadFontResource(asm, "Fonts.Roboto.ttf");
    var alamakFont = LoadFontResource(asm, "Fonts.Alamak.ttf");

    _demos = new List<IDemo>
    {
        new CanvasDemo(_canvas, wallTexture!, robotoFont!, alamakFont!),
        new SVGDemo(_canvas),
        new BenchmarkScene(_canvas, robotoFont!),
    };
}
The per-frame callback receives a delta time from the JS requestAnimationFrame scheduler:
[JSExport]
internal static void OnFrame(double deltaTimeD)
{
    if (_demos.Count == 0) return;

    float deltaTime = Math.Clamp((float)deltaTimeD, 0.001f, 0.1f);

    if (_keyQ) _rotation += 10f * deltaTime;
    if (_keyE) _rotation -= 10f * deltaTime;

    var (cw, ch) = _renderer.GetCanvasSize();
    _canvas.BeginFrame(cw, ch);
    _demos[_currentDemoIndex].RenderFrame(deltaTime, _offset, _zoom, _rotation);
    _canvas.Render();
}
Input events are forwarded from JS DOM event listeners:
[JSExport]
internal static void OnMouseMove(double x, double y)
{
    _prevMouseX = _mouseX; _prevMouseY = _mouseY;
    _mouseX = x; _mouseY = y;

    if (_mouseDown)
    {
        float dx = (float)(_mouseX - _prevMouseX);
        float dy = (float)(_mouseY - _prevMouseY);
        _offset.X += dx * (1f / _zoom);
        _offset.Y += dy * (1f / _zoom);
    }
}

[JSExport] internal static void OnMouseDown() => _mouseDown = true;
[JSExport] internal static void OnMouseUp()   => _mouseDown = false;

[JSExport]
internal static void OnWheel(double deltaY)
{
    _zoom += -(float)deltaY * 0.001f;
    if (_zoom < 0.1f) _zoom = 0.1f;
}

[JSExport]
internal static void OnKeyDown(string key)
{
    switch (key)
    {
        case "q": case "Q": _keyQ = true; break;
        case "e": case "E": _keyE = true; break;
        case "ArrowLeft":
            _currentDemoIndex = _currentDemoIndex - 1 < 0 ? _demos.Count - 1 : _currentDemoIndex - 1;
            break;
        case "ArrowRight":
            _currentDemoIndex = (_currentDemoIndex + 1) % _demos.Count;
            break;
    }
}

WebGLCanvasRenderer — The ICanvasRenderer Implementation

WebGLCanvasRenderer uses integer texture IDs rather than GPU objects. Quill receives these IDs as opaque object references, and the renderer maintains a dictionary mapping ID → size. The actual WebGL texture lives on the JavaScript side, keyed by the same integer:
public class WebGLCanvasRenderer : ICanvasRenderer
{
    private int _nextTextureId = 1;
    private readonly Dictionary<int, (int w, int h)> _textureSizes = new();

    private const int VERTEX_SIZE = 20; // 8 position + 8 uv + 4 color
    private const int DC_INFO_STRIDE = 2; // texId, elemCount
}

Texture Methods

public object CreateTexture(uint width, uint height)
{
    int texId = _nextTextureId++;
    _textureSizes[texId] = ((int)width, (int)height);
    WebGLInterop.CreateTexture(texId, (int)width, (int)height);
    return texId;
}

public Int2 GetTextureSize(object texture)
{
    int texId = (int)texture;
    if (_textureSizes.TryGetValue(texId, out var size))
        return new Int2(size.w, size.h);
    return new Int2(0, 0);
}

public void SetTextureData(object texture, IntRect bounds, byte[] data)
{
    int texId = (int)texture;
    WebGLInterop.SetTextureData(texId,
        bounds.Min.X, bounds.Min.Y,
        bounds.Size.X, bounds.Size.Y,
        data);
}

RenderCalls — Serialisation Across the JS Boundary

Because calling into JavaScript has non-trivial overhead, RenderCalls marshals the entire frame into a small set of pre-allocated typed arrays and forwards them to the JS renderer in a single WebGLInterop.Render call:
public void RenderCalls(Canvas canvas, IReadOnlyList<DrawCall> drawCalls)
{
    if (drawCalls.Count == 0) return;

    int vertexCount = canvas.Vertices.Count;
    int indexCount  = canvas.Indices.Count;
    int dcCount     = drawCalls.Count;

    // Serialise vertices to raw bytes (20 bytes each: x,y,u,v + r,g,b,a)
    var vertexBuffer = new byte[vertexCount * VERTEX_SIZE];
    for (int i = 0; i < vertexCount; i++)
    {
        var v      = canvas.Vertices[i];
        int offset = i * VERTEX_SIZE;
        BitConverter.TryWriteBytes(vertexBuffer.AsSpan(offset),      v.x);
        BitConverter.TryWriteBytes(vertexBuffer.AsSpan(offset + 4),  v.y);
        BitConverter.TryWriteBytes(vertexBuffer.AsSpan(offset + 8),  v.u);
        BitConverter.TryWriteBytes(vertexBuffer.AsSpan(offset + 12), v.v);
        vertexBuffer[offset + 16] = v.r;
        vertexBuffer[offset + 17] = v.g;
        vertexBuffer[offset + 18] = v.b;
        vertexBuffer[offset + 19] = v.a;
    }

    // Serialise indices
    var indexBuffer = new int[indexCount];
    for (int i = 0; i < indexCount; i++)
        indexBuffer[i] = (int)canvas.Indices[i];

    // Serialise per-draw-call info: [texId, elemCount] per entry
    var dcInfoBuffer = new int[dcCount * DC_INFO_STRIDE];
    // Serialise scissor matrices (16 doubles) + extent (2 doubles) = 18 per draw call
    var scissorBuffer = new double[dcCount * 18];
    // Serialise brush data: type(1) + brushMat(16) + color1(4) + color2(4)
    //   + params(4) + params2(2) + textureMat(16) = 47 per draw call
    var brushBuffer = new double[dcCount * 47];

    for (int i = 0; i < dcCount; i++)
    {
        var dc  = drawCalls[i];
        int di  = i * DC_INFO_STRIDE;
        int s   = i * 18;
        int b   = i * 47;

        dcInfoBuffer[di]     = dc.Texture != null ? (int)dc.Texture : 0;
        dcInfoBuffer[di + 1] = dc.ElementCount;

        dc.GetScissor(out var scissorMat, out var scissorExt);
        for (int col = 0; col < 4; col++)
            for (int row = 0; row < 4; row++)
                scissorBuffer[s + col * 4 + row] = scissorMat[row, col];
        scissorBuffer[s + 16] = scissorExt.X;
        scissorBuffer[s + 17] = scissorExt.Y;

        brushBuffer[b] = (int)dc.Brush.Type;
        var bm = dc.Brush.BrushMatrix;
        for (int col = 0; col < 4; col++)
            for (int row = 0; row < 4; row++)
                brushBuffer[b + 1 + col * 4 + row] = bm[row, col];

        brushBuffer[b + 17] = dc.Brush.Color1.R / 255.0;
        brushBuffer[b + 18] = dc.Brush.Color1.G / 255.0;
        brushBuffer[b + 19] = dc.Brush.Color1.B / 255.0;
        brushBuffer[b + 20] = dc.Brush.Color1.A / 255.0;
        brushBuffer[b + 21] = dc.Brush.Color2.R / 255.0;
        // ... remaining Color2, Points, CornerRadii, Feather, TextureMatrix ...
    }

    double scale = canvas.FramebufferScale;
    WebGLInterop.Render(vertexBuffer, indexBuffer, dcInfoBuffer,
                        scissorBuffer, brushBuffer, scale);
}

public void Dispose() { }

Loading Embedded Resources

Assets are loaded from assembly embedded resources rather than the filesystem. Here is the texture loading helper used in Init:
private static object? LoadTextureResource(Assembly asm, string logicalName)
{
    using var stream = asm.GetManifestResourceStream(logicalName);
    if (stream == null) return null;

    var image = ImageResult.FromStream(stream, ColorComponents.RedGreenBlueAlpha);
    var texId = _renderer.CreateTexture((uint)image.Width, (uint)image.Height);
    _renderer.SetTextureData(texId,
        new IntRect(0, 0, image.Width, image.Height),
        image.Data);
    return texId;
}
The WASM backend does not support SupportsBackdropBlur. Backdrop blur requires direct access to framebuffer contents via a blit or capture API, which is not available through the current WebGLInterop layer.
To minimise payload size, publish with dotnet publish -c Release. The WASM runtime will tree-shake unused assemblies. For further compression, enable Brotli encoding on your web server for .wasm files.

Build docs developers (and LLMs) love