-
Notifications
You must be signed in to change notification settings - Fork 0
Manually setting up FNA Project for WASM
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.
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.
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.
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.
In the new project, do the following changes:
- Reference your Class Library project.
- Download or build from source
SDL2.a,FNA3D.a, andlibmojoshader.abuilt for Emscripten/WASM. At time of writing, there is an action that builds them as a release here. - Create a
libfolder, subfolders forSDL2andFNA3D, abrowser-wasmfolder 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. - Change the
<body>tag inwwwroot/index.htmlto instead contain only this:<canvas id="canvas"></canvas> - Change
Program.csto 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);
}- 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();- In the root of the
MyProject.Wasmproject, add a directory calledjs, and create a new file calledlibrary-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
},
});
}- Edit
MyProject.Wasm.csprojas text, and add the following (change thelibpaths 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>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.
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).
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:
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.
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.
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
This page lists a number of issues/limitations that have limited or no workarounds.
- dotnet template
- Audio explanation and harness for start-up context
- 3D/Effects