@yostane

YCoding

#TechAtWorldline

Yassine

Benabbas

DevRel

Teacher

Video game collector

jobs.worldline.com

Web Assembly (WASM)

  • Portable binary instruction format
  • Runs on web browsers (not only)
  • Faster than JS on compute intensive tasks
  • Many programming languages compile to WASM
(module
  (func (result i32)
    (i32.const 42)
  )
  (export "getUniversalNumber" (func 0))
)
00 61 73 6d 01 00 00 00  01 05 01 60 00 01 7f 03  |.asm.......`....|
02 01 00 07 16 01 12 67  65 74 55 6e 69 76 65 72  |.......getUniver|
73 61 6c 4e 75 6d 62 65  72 00 00 0a 06 01 04 00  |salNumber.......|
41 2a 0b 00 0a 04 6e 61  6d 65 02 03 01 00 00     |A*....name.....|

wat format (human readable)

wasm file

https://github.com/WebAssembly/wabt

Browser

Compiler

WASM runtime

JS engine

Based on: https://wasmlabs.dev/articles/docker-without-containers/

binary

WASM in browsers

Source code

Operating system

Compiler

WASM runtime

Based on: https://wasmlabs.dev/articles/docker-without-containers/

Source code

binary

WASM + WASI on the OS

WASI, WASI-NN, Proxy-Wasm

FileSystem

Network

...

https://twitter.com/solomonstre/status/1111004913222324225

WASM on the browser

  • A lot of languages compile to WASM
  • JS interop (DOM)

Need to do something related to WASM

.NET on the browser

  • .NET: OSS cross-platform framework
  • C# language
  • In 2020 .Net 5 introduced Blazor WASM ​​​
    • Component based
    • .NET runs on the browser
@page "/counter"

<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;
    private void IncrementCount() { currentCount++; }
}

A Razor component

Let's port a .NET game !

Game porting

  • Make a game run in platforms other than its original ones
  • By rewriting / adapting the source code for the new platform(s)
  • Not porting: virtual machine or emulator

MVG's video is a great source of inspiration

  • Released in 1993 for DOS
  • One the most successful First Person Shooters
  • Has two parts:
    • engine: Game logic + I/O
    • WAD file: all assets and maps

🌟 Doom is portable by design 🌟

http://mrglitchsreviews.blogspot.com/2012/09/doom-console-ports.html

Ported to a LOT of platforms

https://www.link-cable.com/top-10-weird-doom-ports/

Source: https://www.yahoo.com/news/1995-bill-gates-gave-crazy-180900044.html

ManagedDoom

  • .NET Port of LinuxDoom (official source code)
  • ManagedDoom V1 Uses SFML (graphics + audio + input)
    • V2 uses silk.net (released 27 Dec 2022)
  • My work is a fork of ManagedDoom V1

id-Software/DOOM

sinshu/managed-doom

yostane/MangedDoom-Blazor

Porting strategy

  • Compile the app: replace SFML code with "TODO: implement"
  • Implement TODOs little by little, priority to frame rendering
  • Optimize and clean later strategy
  • Read Doom-Wiki and SFML documentation only when necessary
    • Used to understand frame format

2 weeks

as a side-project

creator.nightcafe.studio

Game loop in C#

while (waitForNextFrame()){
  const input = getPlayerInput();
  const { frame, audio } 
  	      = UpdateGameState(input, WAD);
  render(frame);
  play(audio);
}

Game loop in JS

function gameLoop(){
  if (canAdvanceFrame()){
    const input = getPlayerInput();
    const { frame, audio } 
  	      = UpdateGameState(input, WAD);
    render(frame);
    play(audio);
  }
  requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);

ManagedDoom V1 architecture

For loop (game loop)

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

Frame buffer

Audio buffer

SFML Audio

SFML Video

 1 iteration of the Doom Engine

Updates the game state

wad

DOOM.wad

Blazor

~70% OK

Blazor

Blazor Doom architecture

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

wad

DOOM.wad

requestAnimationFrame

DotNet.invokeMethod

Audio buffer

Frame buffer

Canvas

Audio Context

IJSRuntime.Invoke

IJSRuntime.InvokeUnmarshalled

 1 iteration of the Doom Engine

Updates the game state

<canvas id="canvas" width="320" height="200" 
  style="width:100%; height:auto; image-rendering: pixelated;">
</canvas>
@code {
  // Entry point of the game
  private async Task StartGame()
  {
    // steup the game object
    app = new ManagedDoom.DoomApplication();
    // Calls JS method that manages "requestAnimationFrame" pacing
    jsProcessRuntime.InvokeVoid("gameLoop");
  }

  // Runs a frame of the game engine.
  // Called after each "requestAnimationFrame"
  [JSInvokable("UpdateGameState")]
  public static void UpdateGameState(uint[] downKeys, uint[] upKeys)
  {
    app.Run(downKeys, upKeys);
  }
}

Entry point: The main component

Frame pacing with JS

window.gameLoop = function (timestamp) {
  // Check the pacing
  if (timestamp - lastFrameTimestamp >= frameTime) {
    lastFrameTimestamp = timestamp;
    // Invoke the game engine to iterate once (advance a frame)
    DotNet.invokeMethod('BlazorDoom', 'UpdateGameState', 
                        downKeys, upKeys);
  }
  // This replaces the for loop in a traditional game
  // Request the browser to notify us when we do the next iteration
  window.requestAnimationFrame(window.gameLoop);
}
// The code that I showed earlier
[JSInvokable("UpdateGameState")]
public static void UpdateGameState(uint[] downKeys, uint[] upKeys)
{
  app.Run(downKeys, upKeys);
}

Game engine execution with C#

Audio playback

0.5 1 0.75 0 -0.75 -1 -0.5 0

https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Audio_concepts

AudioContext

Sample

1 / Sampling frequency

+ Sampling frequency

🔊

playSound(samples, sampleRate) {
  const audioBuffer = this.context.createBuffer(
    1, length, this.context.sampleRate
  );
  var channelData = audioBuffer.getChannelData(0);
  // JS receives a weird "samples" array
  for (let i = 0; i < length; i += 2) {
    // Scale the value to between -1 and 1
    channelData[i] = samples[i] / 0xffff;
  }
  // Play the audio
  var source = this.context.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(this.context.destination);
  source.start();
}

Audio playback

// Somewhere in the Doom Engine's audio module
DoomApplication.WebAssemblyJSRuntime.Invoke<object>(
	"playSound", new object[] { samples, sampleRate, 0, Position }
);
0 1 2 3 1 1 2 3
0 1 2 3

From 1D frame to a 2D frame

2 2 0 1

Frame data

Color palette

Canvas

  • Doom uses color indexing

  • Image built from top to bottom and from left to right

C# Byte Array to JS in .NET < 7

0 1 2 3 1 1 2 3
2 2 0 1

Frame data

0123

1123

2201

  • 4 bytes in C# -> 1 number in JS

  • n elements in C# -> (n / 4) elements in JS

  • Bit shifting required in JS !

IJSRuntime.InvokeUnmarshalled

window.renderWithColorsAndScreenDataUnmarshalled = (screenData, colors) => {
  // JS receives an array with 4 bytes per item
  for (var i = 0; i < (width * height) / 4; i += 1) {
    // Gets the array sent from C#
    const screenDataItem = BINDING.mono_array_get(screenData, i);
    for (let mask = 0; mask <= 24; mask += 8) {
      let dataIndex = y * (width * 4) + x;
      setSinglePixel(imageData, dataIndex, colors, 
                     (screenDataItem >> mask) & 0xff);
      // Build the image from top to bottom, left to right
      if (y >= height - 1) { y = 0; x += 4; } else { y += 1; }
    }
  }
  context.putImageData(imageData, 0, 0);
};
function setSinglePixel(imageData, dataIndex, colors, colorIndex) {
  const color = BINDING.mono_array_get(colors, colorIndex);
  imageData.data[dataIndex] = color & 0xff;
  imageData.data[dataIndex + 1] = (color >> 8) & 0xff;
  imageData.data[dataIndex + 2] = (color >> 16) & 0xff;
  imageData.data[dataIndex + 3] = 255;
}

Rendering a Frame buffer

// Somwhere in the Doom Engine's graphics module
var args = new object[] { screen.Data, colors, 320, 200 };
// Send the frame buffer to JS
DoomApplication.WebAssemblyJSRuntime.InvokeUnmarshalled<byte[], uint[], int>
	("renderWithColorsAndScreenDataUnmarshalled", screen.Data, colors);

Tips and lessons learned

  • Calling Blazor from JS is very fast
    • But has problems with certain data types in .NET < 7
    • Undocumented APIs removed in .Net 7 in favor of JS Interop
  • This slooooows the app:
    • Extensive logging
    • Array.Copy of Big arrays (in .Net 5) 

Blazor

JS

  • window.requestAnimationFrame to pace frames on the web

  • Browsers require interaction with the page to play audio

JS Interop in .net >= 7

  • Less intricate way to run .Net from JS (no components)
<html>
  <head>
    <script type="module" src="./main.js"></script>
    <!-- Load the .net runtime,
 		which will load our c# code as dll ! -->
    <script type="module" src="./dotnet.js"></script>
    <link rel="prefetch" href="./dotnet.wasm"/>
   </head>
  <body>
  	<canvas id="canvas" width="320" height="200" 
            style="image-rendering: pixelated" />
  </body>
</html>

JS Interop in .net >= 7

public partial class MainJS // this name is required
{
  public static void Main()
  {
    app = new ManagedDoom.DoomApplication();
  }
  
  [JSExport] // Can be imported from JS
  public static void UpdateGameState(int[] keys)
  {
     managedDoom.UpdateGameState(keys);
  }
}
import { dotnet } from "./dotnet.js";
const { getAssemblyExports, getConfig } = await dotnet.create();
const exports = await getAssemblyExports(getConfig().mainAssemblyName);
await dotnet.run();

function gameLoop(timestamp) {
    if (canAdvanceFrame) {
        exports.BlazorDoom.MainJS.UpdateGameState(keys);
    }
    requestAnimationFrame(gameLoop);
}

Frame Rendering with JS Interop

export function drawOnCanvas(screenData, colors) {
  for (let i = 0; i < screenData.length; i += 1) {
    const dataIndex = (y * width + x) * 4;
    setSinglePixel(imageData, dataIndex, colors, screenData[i]);
    if (y >= height - 1) {
      y = 0;
      x += 1;
    } else {
      y += 1;
    }
  }
}

Frame Rendering with JS Interop

namespace BlazorDoom
{
    [SupportedOSPlatform("browser")]
    public partial class Renderer
    {
        [JSImport("drawOnCanvas", "blazorDoom/renderer.js")]
        internal static partial string renderOnJS(byte[] screenData,
                                                   int[] colors);
    }
}
export function drawOnCanvas(screenData, colors) {
  // 
}
public class DoomRenderer
{
  // Called by updateGameState
  private void Display(uint[] colors)
  {
    var args = new object[] { screen.Data, colors, 320, 200 };
    BlazorDoom.Renderer.renderOnJS(screen.Data, (int[])((object)colors));
  }
}

Another port possibility

id-Software/DOOM

sinshu/managed-doom

V1

V2

Silk.net

Blazor Web Assembly Support: dotnet/Silk.NET/issues/705

Next steps

  • Short term:
    • Continue migration to .NET 7 JSInterop
    • Maybe add Music support
  • Middle term:
    • Update to ManagedDoom V2
    • Experiment with Silk.Net is adds WASM support
  • Long term / wish:
    • Make this port an official part of ManagedDoom

#TechAtWorldline

jobs.worldline.com

Links

  • https://mspoweruser.com/this-doom-digital-camera-source-port-is-amazing-and-bizarre/
  • https://www.link-cable.com/top-10-weird-doom-ports/
  • pixabay.com
Made with Slides.com