Skip to content

Manually setting up FNA Project for WASM

Mike Roibu edited this page Mar 9, 2024 · 3 revisions

This page describes step-by-step the process to take an existing FNA project and get it to build for WebAssembly. This page assumes you've followed the official guide, so your project is set up as a single C# Console project, containing Program.cs, your own Game class and anything else you needed. It also assumes you're using .NET Core, specifically .NET 8.

Pre-requisites

Have .NET 8 or newer installed. Run dotnet workload install wasm-tools and dotnet workload install wasm-experimental. Your solution has FNA.Core imported as a project, and referenced from your project.

Split Game to Class Library

In your solution, create a new Class Library project, and reference it from your Console project. Move everything but the Program class to the new Class Library, everything should continue building/running as normal.

You will need to make sure the Class Library correctly references FNA, but the FNAlibs dependencies should continue to be added by the Console project!

This will allow you to build/run your Console project for Windows/etc platforms.

Create WASM Project

In your solution, run dotnet new wasmbrowser -o MyProject.Wasm which will create a new project.

This project is what you will build/run/publish for WebAssembly builds.

Set up WASM Project

In the new project, do the following changes:

  1. Reference your Class Library project.
  2. Download or build from source SDL2.a, FNA3D.a, and libmojoshader.a built for Emscripten/WASM. At time of writing, there is an action that builds them as a release here.
  3. Create a lib folder, subfolders for SDL2 and FNA3D, a browser-wasm folder in each subfolder, and put each file into the right one. Use a different layout if you have non-WASM binaries already, but adjust the paths later in these steps.
  4. Change the <body> tag in wwwroot/index.html to instead contain only this: <canvas id="canvas"></canvas>
  5. Change Program.cs to contain the following:
public static partial class Program
{
    internal static void Main()
    {
        Console.WriteLine("Setting up main loop");
        SetMainLoop(MainLoop);
    }

    private static bool _firstRun = true;
    private static DateTime _lastLog = DateTime.UnixEpoch;
    private static Game _myGame;

    private static void MainLoop()
    {
        try
        {
            if (_firstRun)
            {
                Console.WriteLine("First run of the main loop");
                _firstRun = false;
            
                _myGame = new MyGame(); //replace this with your Game subclass
            }

            var now = DateTime.UtcNow;
            if ((now - _lastLog).TotalSeconds > 1.0)
            {
                _lastLog = now;
                Console.WriteLine($"Main loop still running at: {now}");
            }

            if (_myGame != null)
            {
                _myGame.RunOneFrame();
            }
        }
        catch (Exception e)
        {
            Console.Error.WriteLine(e);
            throw;
        }
    }

    [JSImport("setMainLoop", "main.js")]
    internal static partial void SetMainLoop([JSMarshalAs<JSType.Function>] Action cb);
}
  1. Inside wwwroot/main.js, replace everything after .create(); with:
setModuleImports('main.js', {
    setMainLoop: (cb) => dotnet.instance.Module.setMainLoop(cb)
});

//set canvas
var canvas = document.getElementById("canvas");
dotnet.instance.Module.canvas = canvas;
await dotnet.run();
  1. In the root of the MyProject.Wasm project, add a directory called js, and create a new file called library-fixemscripten.js, containing this exactly (this is a fix for an issue in .NET 8 regarding Emscripten 3.1.34 (dotnet's version) builds of modern SDL; this may not be necessary in the future):
/**
 * This file exists because dotnet uses Emscripten 3.1.34, whereas modern SDL requires 3.1.35+,
 * this is because these two methods moved from the runtime to the library, and therefore SDL code
 * references them as library functions. This should work as a shim to call the runtime.
 * 
 * Unfortunately, the entire function from 3.1.34 runtime_strings.js has to be copied in, because
 * otherwise there's no way to reference the one from the runtime (it gets overwritten).
 */
if (
    (DEFAULT_LIBRARY_FUNCS_TO_INCLUDE.indexOf("$stringToUTF8") >= 0) && //if it's 3.1.35+ code and was built with 3.1.34
    !("$stringToUTF8__deps" in LibraryManager.library)    //and it's being linked in Emscripten 3.1.34
) {
    if (VERBOSE) {
        warn(`Forcing $stringToUTF8 as a library shim`)
    }
    mergeInto(LibraryManager.library, {
        '$stringToUTF8__internal': true,
        '$stringToUTF8': function (str, outPtr, maxBytesToWrite) {
            #if ASSERTIONS
            assert(typeof maxBytesToWrite == 'number', 'stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!');
            #endif
            return stringToUTF8Array(str, {{{ heapAndOffset('HEAPU8', 'outPtr') }}}, maxBytesToWrite);
        }
    });
}

if (
    (DEFAULT_LIBRARY_FUNCS_TO_INCLUDE.indexOf("$UTF8ToString") >= 0) && //if it's 3.1.35+ code and was built with 3.1.34
    !("$UTF8ToString__deps" in LibraryManager.library)    //and it's being linked in Emscripten 3.1.34
) {
    if (VERBOSE) {
        warn(`Forcing $UTF8ToString as a library shim`)
    }
    mergeInto(LibraryManager.library, {
        '$UTF8ToString__internal': true,
        '$UTF8ToString': function (ptr, maxBytesToRead) {
            #if ASSERTIONS
            assert(typeof ptr == 'number');
            #endif
            #if CAN_ADDRESS_2GB
            ptr >>>= 0;
            #endif
            #if TEXTDECODER == 2
            if (!ptr) return '';
            var maxPtr = ptr + maxBytesToRead;
            for (var end = ptr; !(end >= maxPtr) && HEAPU8[end];) ++end;
            return UTF8Decoder.decode({{{ getUnsharedTextDecoderView('HEAPU8', 'ptr', 'end') }}});
            #else
            return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : '';
            #endif
        },
    });
}
  1. Edit MyProject.Wasm.csproj as text, and add the following (change the lib paths if you are using a different layout, but point at the Emscripten ones):
    <PropertyGroup>
        <EmccLinkOptimizationFlag>-O1</EmccLinkOptimizationFlag>
        <EmccExtraLDFlags>--js-library $(MSBuildProjectDirectory.Replace('\', '/'))/js/library-fixemscripten.js -sFULL_ES3</EmccExtraLDFlags>
        <WasmEmitSymbolMap>true</WasmEmitSymbolMap>
        <WasmAllowUndefinedSymbols>true</WasmAllowUndefinedSymbols>
    </PropertyGroup>
    <ItemGroup>
        <NativeFileReference Include="..\lib\SDL2\browser-wasm\SDL2.a">
            <Visible>false</Visible>
        </NativeFileReference>
        <NativeFileReference Include="..\lib\FNA3D\browser-wasm\FNA3D.a">
            <Visible>false</Visible>
        </NativeFileReference>
        <NativeFileReference Include="..\lib\FNA3D\browser-wasm\libmojoshader.a">
            <Visible>false</Visible>
        </NativeFileReference>
        <EmccExportedRuntimeMethod Include="SDL">
            <Visible>false</Visible>
        </EmccExportedRuntimeMethod>
        <EmccExportedRuntimeMethod Include="GL">
            <Visible>false</Visible>
        </EmccExportedRuntimeMethod>
        <EmccExportedRuntimeMethod Include="setMainLoop">
            <Visible>false</Visible>
        </EmccExportedRuntimeMethod>
    </ItemGroup>

Build/Run WASM Project

Tip: To ensure your project starts on the same port (otherwise you get a random port every time), either set an environment variable, or otherwise via your IDE, of ASPNETCORE_URLS set to something like: https://localhost:51001;http://localhost:51000. This will make the project always run on https://localhost:51001 when run locally.

After running your project, open the URL given in a browser. The Developer Tools should show you log entries (Console.WriteLine from C# side, and console.log from JS side) as well as error logs (Console.Error.WriteLine from C# side, and console.error from JS side).

Build/Run will basically be how you test things locally.

Publish WASM Project

When you publish your project (dotnet publish MyProject.Wasm), a publish folder will be created in bin/... that contains some files and a wwwroot folder. Everything inside the wwwroot folder is what you need to put on a web server in order to expose your game. The rest isn't relevant.

Because it's WASM, this even allows you to host it on Github Pages, or any such host (as long as it allows .wasm files).

Assets/File System

Unfortunately in .NET 8 there is no built-in VFS support as part of the build. The JS functions for it exist, but there's no MSBuild targets/tasks that set it up so a bunch of files are automatically loaded to VFS.

We'll use an image test1.png as an example. There are two approaches you can take for this:

Minimal Asset System

This is probably best to start off with because it's simple to track down issues and it's very obvious what's happening. Switching to the more complex system is also possible later.

In your Web project, create an assets folder in wwwroot, and add an img subfolder to it. Then put the image inside it, so that the file is under /wwwroot/assets/img/test1.png. If you ran the project now, you would be able to see the image at: https://localhost:51001/assets/img/test1.png, but loading it via File.ReadAllBytes, or Content.Load<Texture2D>, would fail.

In main.js, on the await dotnet line, turn it into this:

const { setModuleImports, getAssemblyExports, getConfig } = await dotnet
    .withModuleConfig({
        onConfigLoaded: (config) => {
            if (!config.resources.vfs) {
                config.resources.vfs = {}
            }

            config.resources.vfs["/assets/img/test1.png"] = { "../assets/img/test1.png" };
            //add other files here
        },
    })
    .create();

Re-run the project and then refresh the page. You should now be able to also load the file via File or Content.Load<T> using the string /assets/img/test1.png (it has to match pretty much exactly).

For every asset change, you have to update main.js to add/change it there.

Complex Asset System

This is an automated system that aims to avoid the need to update main.js. It uses a set of custom MSBuild targets, and a small JS harness, to automate this update process.

Create the same layout in wwwroot.

In your .csproj, add these elements:

    <PropertyGroup>
        <CustomAssetManifestRoot>wwwroot</CustomAssetManifestRoot>
        <CustomAssetManifestFile>asset_manifest.csv</CustomAssetManifestFile>
        <CustomAssetManifestVerbose>false</CustomAssetManifestVerbose>
        <CustomAssetManifestVerbose Condition="$(Configuration) == 'Debug'">true</CustomAssetManifestVerbose>
    </PropertyGroup>
    <ItemGroup>
        <Content Remove="$(CustomAssetManifestRoot)\$(CustomAssetManifestFile)" />
        <CustomAssets Include="wwwroot\assets\**\*"/>
    </ItemGroup>
    <Target Name="CustomAssetManifest_PackageAssets" BeforeTargets="AssignTargetPaths">
        <PropertyGroup>
            <IntermediateCustomAssetManifestRootPath>$(IntermediateOutputPath)</IntermediateCustomAssetManifestRootPath>
            <IntermediateCustomAssetManifestPath>$(IntermediateCustomAssetManifestRootPath)$(CustomAssetManifestFile)</IntermediateCustomAssetManifestPath>
        </PropertyGroup>
        <ItemGroup>
            <CustomAssetsNoRoot Include="@(CustomAssets -> '/assets/%(RecursiveDir)%(Filename)%(Extension)')" />
        </ItemGroup>

        <Message Text="No custom assets to bundle" Condition="@(CustomAssetsNoRoot->Count()) == 0 and $(CustomAssetManifestVerbose) == 'true'" />
        <Message Text="Found @(CustomAssetsNoRoot->Count()) custom assets: @(CustomAssetsNoRoot)" Condition="@(CustomAssetsNoRoot->Count()) > 0 and $(CustomAssetManifestVerbose) == 'true'" />
        <Message Text="Outputting custom asset manifest to: $(IntermediateCustomAssetManifestPath)" />
        <WriteLinesToFile
                File="$(IntermediateCustomAssetManifestPath)"
                Lines="@(CustomAssetsNoRoot)"
                Overwrite="true"
        />

        <ItemGroup>
            <FileWrites Include="$(IntermediateCustomAssetManifestPath)" />
        </ItemGroup>

        <ItemGroup>
            <IntermediateCustomAssetManifestItem
                    Include="$(IntermediateCustomAssetManifestPath)"
                    RelativePath="$(CustomAssetManifestFile)"
            />
        </ItemGroup>
    </Target>

    <Target Name="CustomAssetManifest_Build" DependsOnTargets="CustomAssetManifest_PackageAssets" BeforeTargets="GenerateStaticWebAssetsManifest">
        <DefineStaticWebAssets
                CandidateAssets="@(IntermediateCustomAssetManifestItem)"
                SourceId="$(PackageId)"
                SourceType="Computed"
                AssetKind="Build"
                AssetRole="Primary"
                CopyToOutputDirectory="PreserveNewest"
                CopyToPublishDirectory="Never"
                ContentRoot="$(OutDir)/wwwroot"
                BasePath="/"
                RelativePathPattern="$(IntermediateCustomAssetManifestRootPath)"
                RelativePathFilter=""
                Condition=""
        >
            <Output TaskParameter="Assets" ItemName="CustomAssetManifestAsset" />
        </DefineStaticWebAssets>

        <ItemGroup>
            <StaticWebAsset Include="@(CustomAssetManifestAsset)" />
        </ItemGroup>
    </Target>

    <Target Name="CustomAssetManifest_Publish" DependsOnTargets="CustomAssetManifest_PackageAssets" BeforeTargets="ComputeFilesToPublish">
        <DefineStaticWebAssets
                CandidateAssets="@(IntermediateCustomAssetManifestItem)"
                SourceId="$(PackageId)"
                SourceType="Computed"
                AssetKind="Publish"
                AssetRole="Primary"
                CopyToOutputDirectory="Never"
                CopyToPublishDirectory="PreserveNewest"
                ContentRoot="$(PublishDir)/wwwroot"
                BasePath="/"
                RelativePathPattern="$(IntermediateCustomAssetManifestRootPath)"
                RelativePathFilter=""
                Condition=""
        >
            <Output TaskParameter="Assets" ItemName="CustomAssetManifestAsset" />
        </DefineStaticWebAssets>

        <ItemGroup>
            <StaticWebAsset Include="@(CustomAssetManifestAsset)" />
        </ItemGroup>
    </Target>

This says that on build/publish it will create an asset_manifest.csv file with one asset path per line, matching every file inside wwwroot/assets folder.

Then change main.js and add this code before the await dotnet line, but after the import { dotnet } line:

//we load the asset manifest early so that we can use it to set the dotnet config
let assetManifest = await globalThis.fetch("asset_manifest.csv");
let assetManifestText = "";
if (!assetManifest.ok)
{
    console.error("Unable to load asset manifest");
    console.error(assetManifest);
}
else {
    assetManifestText = await assetManifest.text();
}
let assetList = assetManifestText.split('\n')
    .filter(i => i)
    .map(i => i.trim().replace('\\', '/'));
console.log(`Found ${assetList.length} assets in manifest`);

To speed up this download, also add this line to index.html above <script type='module': <link rel="preload" href="./asset_manifest.csv" as="fetch" crossorigin="anonymous">

Then finally edit main.js to add this to the await dotnet line:

    .withModuleConfig({
        onConfigLoaded: (config) => {
            if (!config.resources.vfs) {
                config.resources.vfs = {}
            }

            for (let asset of assetList)
            {
                asset = asset.trim();
                console.log(`Found ${asset}, adding to VFS`);
                config.resources.vfs[asset] = {};
                const assetPath = `../${asset}`;
                config.resources.vfs[asset][assetPath] = null;
            }
        },
    })

This uses the previously downloaded file to populate the VFS.

VFS Notes

You should be aware that VFS is not an ideal system, and it has a number of implications:

  • Populating files into the VFS gets done ahead of the application starting (by default, but changing this in dotnet is non-trivial), so it can slow down application start
  • The VFS is stored in memory, so the more assets loaded, the larger the memory footprint of the application
  • VFS is always synchronous, so loading from it is always fully synchronous; even if you want to stream data from it
  • Future .NET versions will either improve or change this functionality, and potentially break the above approaches

Potential Failures

This page lists a number of issues/limitations that have limited or no workarounds.

TODO things

  • dotnet template
  • Audio explanation and harness for start-up context
  • 3D/Effects