Comment ça a commencé

Destination finale

@yostane

#TechAtWorldline

Yassine

Benabbas

DevRel / Enseignant / membre du LAUG

Amateur de jeux rétro

😍 Kotlin, WASM, AI, ...

blog.worldline.tech

@TechAtWorldline

@techatworldline.bsky.social

@worldlinetech

WebAssembly (WASM)

Format d'instruction binaire portable

Standard W3C

Exécutable par les navigateurs et environnements non-Web

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.....|
(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.....|

format wat

Format texte pour une lecture facile

https://github.com/WebAssembly/wabt

Navigateur

runtime

WASM

Moteur JS

Basé sur: https://wasmlabs.dev/articles/docker-without-containers/

WASM dans le web

Code source

...

Compilateur / Toolchain WASM

wasm-pack

...

Binaire WASM

Code glue

Je dois réaliser un projet en WASM !

.NET et le WASM

.NET : Framework Open Source de dév. d'apps 

Mulit-plateformes et Fullstack

WASM est une cible de .Net :

  • Blazor: framework front
  • wasm-tools: compilation vers WASM
@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++; }
}

Blazor

Framework de développement d'applications web

HTML + CSS + C# (à la place du JS / TS)

Rendu côté serveur ou client (via WASM)

public partial class MyClass
{
    [JSExport]
    internal static string Greeting()
    {
        var text = $"Hello, {GetHRef()}";
        Console.WriteLine(text);
        return text;
    }

    [JSImport("window.location.href", "main.js")]
    internal static partial string GetHRef();
}
setModuleImports('main.js', {
    window: {
        location: {
            href: () => globalThis.window.location.href
        }
    }
});

const text = exports.MyClass.Greeting();
console.log(text);

.NET 7+

wasm-tools

Je dois porter un jeu .NET vers WASM !

Portage de jeux

Pouvoir lancer un jeu sur d'autres plateformes

Réécriture partielle ou complète du code source d'origine

Pas du portage: machine virtuelle ou emulateur

YouTubeur MVG 

Sorti en 1993 sur DOS

🌟 Doom est facilement portable par conception🌟

+

Moteur

Ressources

sf2

wad

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

DOOM a été porté sur plusieurs platformes

25 official licensed ports https://www.thegamer.com/doom-how-many-platforms-ports-consoles

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

Portage d'Olivier Poncet

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

ManagedDoom

  • Port en .NET de LinuxDoom
  • ManagedDoom V1 utilise SFML (V2 utilise silk.net)
  • Mon portage est un fork de ManagedDoom V1

id-Software/DOOM

sinshu/managed-doom

yostane/MangedDoom-Blazor

Stratégie de portage

  • Cloner sininshu/managed-doom
  • Changer la cible de compilation vers WASM
  • Réimplémenter les classes SFML :
    1. Par des bouchons jusqu'à ce que ça compile
    2. Implémenter les bouchons
    3. Faire le minimum de traitements côté JS (rendu)

dall-e

using SFML.Audio;

namespace ManagedDoom.Audio
{
    public sealed class SfmlSound : ISound, IDisposable
    {
        private SoundBuffer[] buffers;
    }
}

Exemple de classe qui dépend de SFML

Non disponible

Le namespace SFML.Audio

La classe SFML SoundBuffer

namespace SFML.Audio
{
    public class SoundBuffer
    {
        public SoundBuffer(short[] samples, int v, uint sampleRate)
        {
        	// TODO: implement
        }

        internal void Dispose()
        {
            // TODO: implement
        }
    }
}

Alors, implémentons là

namespace SFML.Audio
{
    public class SoundBuffer
    {
        public short[] samples;
        private int v;
        public uint sampleRate;

        public SoundBuffer(short[] samples, int v, uint sampleRate)
        {
            this.samples = samples;
            this.v = v;
            this.sampleRate = sampleRate;
        }

        public Time Duration { get; internal set; }
    }
}

Implémentation finale

Boucle de jeu en C#

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

waitForNextFrame() à remplacer par requestAnimationFrame() en JS qui utilise une callback

Boucle de jeu en JS

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

Architecture de ManagedDoom V1

Boucle for (boucle de jeu)

Touches appuyées (keyup)

Touches relâchées (keydown)

['Z', 'Q']

['space']

Frame buffer

Audio buffer

SFML Audio

SFML Video

 1 iteration du Doom Engine

Mise à jour de l'état du jeu

.NET WASM

~70% OK

.NET WASM

wad

DOOM.wad

sf2

SoundFont.sf2

Architecture de Blazor Doom

wad

DOOM.wad

requestAnimationFrame

Appel .Net / WASM

Audio buffer

Frame buffer

Canvas

Audio Context

Appel JS

Appel JS

sf2

SoundFont.sf2

Touches appuyées (keyup)

Touches relâchées (keydown)

['Z', 'Q']

['space']

 1 iteration du Doom Engine

Mise à jour de l'état du jeu

<html>
  <head>
    <!-- Sets .Net interop and starts the game loop -->
    <script type="module" src="./main.js"></script>
   </head>
  <body>
  	<canvas id="canvas" width="320" height="200" 
            style="image-rendering: pixelated" />
  </body>
</html>

Point d'entrée

Exécution de la boucle de jeu

main.js

import { dotnet } from "./dotnet.js";
const { getAssemblyExports, getConfig } = await dotnet.create();
const exports = await getAssemblyExports(getConfig().mainAssemblyName);
await dotnet.run();

function gameLoop(timestamp) {
    if (timestamp - lastFrameTimestamp >= 1000 / 30) {
      	lastFrameTimestamp = timestamp;
        exports.BlazorDoom.MainJS.UpdateGameState(keys);
    }
    requestAnimationFrame(gameLoop);
}

main.js

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)
  { // computes the next frame and sounds
     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 (timestamp - lastFrameTimestamp >= 1000 / 30) {
      	lastFrameTimestamp = timestamp;
        exports.BlazorDoom.MainJS.UpdateGameState(keys);
    }
    requestAnimationFrame(gameLoop);
}

Exécution de la boucle de jeu

Audio

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

Echantillon

1 / fréquence d'échantillonage

+ Fréquence d'échantillonage

🔊

Gestion des bruitages

void PlayCurrentFrameSound(SoundBuffer soundBuffer)
{
	int[] samples = Array.ConvertAll(soundBuffer.samples, Convert.ToInt32);
	BlazorDoom.Renderer.playSoundOnJS(samples, (int)soundBuffer.sampleRate);
}
namespace BlazorDoom
{
    [SupportedOSPlatform("browser")]
    public partial class Renderer
    {
        [JSImport("playSound", "blazorDoom/renderer.js")]
        internal static partial string playSoundOnJS(
        	int[] samples, 
            int sampleRate
        );
    }
}

Gestion des bruitages

export function playSound(samples, sampleRate) {
    audioContext = new AudioContext({
        sampleRate: sampleRate,
    });
    const length = samples.length;
    const audioBuffer = audioContext.createBuffer(
        1,
        length,
        sampleRate
    );

    var channelData = audioBuffer.getChannelData(0);
    for (let i = 0; i < length; i++) {
        // noralize the sample to be between -1 and 1
        channelData[i] = samples[i] / 0xffff;
    }

    var source = audioContext.createBufferSource();
    source.buffer = audioBuffer;
    source.connect(audioContext.destination);
    source.start();
}

Musique

SF2: format qui définit quel son que doit émettre chaque note

Note 0

Note 1

Streaming de la musique

sf2

Temps

Temps

Streaming de la musique

Temps

Temps

AudioContext 😢

émet des glitchs sur des petits extraits

+ ne gère pas le streaming nativement

Solution 🎶🥳

Regrouper les extraits en un tampon assez grand

 + planifier le moment de lancement 

sf2

Streaming de la musique

function playMusic(samples) {
  	// On lit le tampon audio s'il est assez rempli
    if (this.#currentMusicBufferIndex >= this.#musicBuffer.length) {
      const currentTime = this.#audioContext.currentTime;
      const duration = this.#currentMusicBufferIndex / this.musicSampleRate;
      // On planifie l'extrait pour qu'il se lance après le précédent
      source.start(this.expectedBufferEndTime, 0, duration);
      this.expectedBufferEndTime = currentTime + duration;
    }
	// Remplissage du tampon avec l'extrait courant
    for (let i = 0; i < samples.length; i++) {
      this.#currentChannelData[this.#currentMusicBufferIndex + i] 
        = samples[i] / 32767;
    }
    this.#currentMusicBufferIndex += samples.length;
}

Inconvénient : la musique se lance avec un retard

(le temps de remplir le premier tampon)

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

Rendu des images

tableau à 1 dimension + palette de couleurs

2 2 0 1

Image

Palette de couleurs

Canvas

12 * 4 bytes (r, g, b, a)

Pixels distribués de haut en bas puis et de gauche à droite

chaque pixel contient l'id de la couleur

Transmission des pixels du C# au JS

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)
  {
    BlazorDoom.Renderer.renderOnJS(screen.Data, (int[])((object)colors));
  }
}

Remplissage du canvas

export function drawOnCanvas(screenData, colors) {
  	const context = getCanvas().context;
    const imageData = context.createImageData(320, 200);
    let y = 0;
    let x = 0;
    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;
        }
    }
    context.putImageData(imageData, 0, 0);
}

function setSinglePixel(imageData, dataIndex, colors, colorIndex) {
    const color = colors[colorIndex];
    imageData.data[dataIndex] = color & 0xff; // R 
    imageData.data[dataIndex + 1] = (color >> 8) & 0xff; // G
    imageData.data[dataIndex + 2] = (color >> 16) & 0xff; // B
    imageData.data[dataIndex + 3] = 255; // Alpha
}

Fire / Tirer

Valider / validate

Open / Ouvrir

Se déplacer

Sélectionner un autre WAD

  • Les possibilités du WASM sont infinies
  • Le portage est accessible à tous
  • Le dév de jeu est un bon moyen pour apprendre tout en s'amusant

@yostane

Yassine Benabbas

#TechAtWorldline

Liens et crédits

  • 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
  • https://www.flaticon.com/ users: freepik
  • Doom text generator: https://c.eev.ee/doom-text-generator 

Veuillez donner votre avis

Diapositives

Code source

[TnT 2025] Comment j'ai porté Doom sur navigateur grâce au Web Assembly

By yostane

[TnT 2025] Comment j'ai porté Doom sur navigateur grâce au Web Assembly

Web Assembly (WASM) is a powerful technology that opens the door to unlimited development possibilities. As a video game enthusiast, I used it to port the Doom game to the browser, allowing me to play it anywhere, even on my mobile. This is made possible thanks to the availability of an Open Source port of Doom to .Net and its support for compilation to WASM. This tools-in-action session will show you how I ported the MangedDoom game, which is made in .Net, to run in a browser. I will also share my experience on carrying out this port. You'll be surprised to see that this kind of project is very accessible as well as captivating, especially when you see the game running on a mobile browser. Although my work is based on .Net's WASM tooling, it can be applied to any framework that targets WASM. So, come and relive this fun porting adventure with me 👍.

  • 61