
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
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 :
- Par des bouchons jusqu'à ce que ça compile
- Implémenter les bouchons
- 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