Skip to content

Commit

Permalink
mono/wasm: Initial WebAssembly support for C# projects
Browse files Browse the repository at this point in the history
Adds initial support for exporting C# Godot projects to WebAssembly:
- Implements WebAssembly runtime integration
- Adds export template system
- Creates build tooling for WASM compilation
- Adds test project for verification

Fixes godotengine#70796
  • Loading branch information
mcjill committed Nov 21, 2024
1 parent 9e60984 commit 387b6a3
Show file tree
Hide file tree
Showing 12 changed files with 754 additions and 0 deletions.
24 changes: 24 additions & 0 deletions modules/mono/SCsub
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,27 @@ env_mono.add_source_files(env.modules_sources, "utils/*.cpp")
if env.editor_build:
env_mono.add_source_files(env.modules_sources, "editor/*.cpp")
SConscript("editor/script_templates/SCsub")

# Add WASM-specific sources
if env['platform'] == 'web':
env_mono.add_source_files(env.modules_sources, [
"wasm/wasm_runtime.cpp",
"wasm/wasm_export_template.cpp"
])

# Copy web templates
if env['target'] == 'template_release' or env['target'] == 'template_debug':
env.Depends('#bin/godot', [
'#modules/mono/wasm/templates/index.html',
'#modules/mono/wasm/templates/dotnet.js'
])

def copy_wasm_templates(target, source, env):
templates_dir = os.path.join(os.path.dirname(str(target[0])), 'wasm_templates')
if not os.path.exists(templates_dir):
os.makedirs(templates_dir)

for src in source:
shutil.copy2(str(src), templates_dir)

env.AddPostAction('#bin/godot', copy_wasm_templates)
112 changes: 112 additions & 0 deletions modules/mono/build_scripts/wasm_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env python

import os
import subprocess
import shutil
import argparse
import json
from typing import List, Dict

def parse_args():
parser = argparse.ArgumentParser(description='Build .NET assemblies for WebAssembly')
parser.add_argument('--project-dir', required=True, help='The directory containing the .NET project')
parser.add_argument('--output-dir', required=True, help='Output directory for the built files')
parser.add_argument('--configuration', default='Release', help='Build configuration (Debug/Release)')
parser.add_argument('--enable-threading', action='store_true', help='Enable threading support')
parser.add_argument('--enable-aot', action='store_true', help='Enable ahead-of-time compilation')
parser.add_argument('--heap-size', type=int, default=512, help='Heap size in MB')
return parser.parse_args()

def ensure_dotnet_wasm_workload():
"""Ensure the .NET WebAssembly workload is installed."""
try:
subprocess.run(['dotnet', 'workload', 'install', 'wasm-tools'], check=True)
except subprocess.CalledProcessError as e:
print(f"Failed to install wasm-tools workload: {e}")
raise

def build_project(args):
"""Build the .NET project for WebAssembly."""
build_props: Dict[str, str] = {
'Configuration': args.configuration,
'RuntimeIdentifier': 'browser-wasm',
'WasmEnableThreading': str(args.enable_threading).lower(),
'WasmEnableExceptionHandling': 'true',
'InvariantGlobalization': 'true',
'EventSourceSupport': 'false',
'UseSystemResourceKeys': 'true',
'WasmHeapSize': str(args.heap_size * 1024 * 1024), # Convert MB to bytes
}

if args.enable_aot:
build_props.update({
'RunAOTCompilation': 'true',
'WasmStripILAfterAOT': 'true',
})

build_args = ['dotnet', 'publish']
for key, value in build_props.items():
build_args.extend(['-p:' + key + '=' + value])
build_args.extend(['-o', args.output_dir])

try:
subprocess.run(build_args, cwd=args.project_dir, check=True)
except subprocess.CalledProcessError as e:
print(f"Build failed: {e}")
raise

def copy_runtime_assets(args):
"""Copy necessary runtime assets to the output directory."""
runtime_dir = os.path.join(args.output_dir, 'runtime')
os.makedirs(runtime_dir, exist_ok=True)

# Copy .NET runtime files
dotnet_files = [
'dotnet.wasm',
'dotnet.js',
'dotnet.timezones.blat',
]

for file in dotnet_files:
src = os.path.join(args.output_dir, file)
dst = os.path.join(runtime_dir, file)
if os.path.exists(src):
shutil.copy2(src, dst)

def generate_assets_list(args):
"""Generate a list of assets that need to be loaded."""
assets: List[Dict[str, str]] = []

for root, _, files in os.walk(args.output_dir):
for file in files:
if file.endswith(('.dll', '.pdb', '.wasm')):
rel_path = os.path.relpath(os.path.join(root, file), args.output_dir)
assets.append({
'name': rel_path,
'path': rel_path,
'type': 'assembly' if file.endswith('.dll') else 'wasm'
})

assets_file = os.path.join(args.output_dir, 'assets.json')
with open(assets_file, 'w') as f:
json.dump(assets, f, indent=2)

def main():
args = parse_args()

print("Ensuring .NET WebAssembly workload is installed...")
ensure_dotnet_wasm_workload()

print(f"Building project for WebAssembly ({args.configuration})...")
build_project(args)

print("Copying runtime assets...")
copy_runtime_assets(args)

print("Generating assets list...")
generate_assets_list(args)

print("Build completed successfully!")

if __name__ == '__main__':
main()
146 changes: 146 additions & 0 deletions modules/mono/wasm/templates/dotnet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// .NET WebAssembly Runtime Loader
const dotnetRuntime = {
// Runtime instance
instance: null,

// Configuration
config: null,

// Initialize the runtime
async init(config) {
this.config = config;

// Load and instantiate the .NET runtime
const response = await fetch('dotnet.wasm');
const wasmBytes = await response.arrayBuffer();

// Create import object for WASM
const imports = {
env: {
// Memory management
memory: new WebAssembly.Memory({
initial: config.heapSize / 64, // 64K pages
maximum: config.heapSize / 64,
shared: config.enableThreading
}),

// Console output
'dotnet_console_log': function(ptr, len) {
const bytes = new Uint8Array(this.instance.exports.memory.buffer, ptr, len);
const text = new TextDecoder().decode(bytes);
console.log(text);
},

// File system operations
'dotnet_read_file': async function(pathPtr, pathLen) {
const path = new TextDecoder().decode(
new Uint8Array(this.instance.exports.memory.buffer, pathPtr, pathLen)
);

try {
const response = await fetch(path);
const data = await response.arrayBuffer();

// Allocate memory for the file data
const ptr = this.instance.exports.malloc(data.byteLength);
new Uint8Array(this.instance.exports.memory.buffer).set(
new Uint8Array(data),
ptr
);

return ptr;
} catch (error) {
console.error('Failed to read file:', path, error);
return 0;
}
}
}
};

// Instantiate WebAssembly module
const wasmModule = await WebAssembly.instantiate(wasmBytes, imports);
this.instance = wasmModule.instance;

// Initialize the runtime
const result = this.instance.exports.dotnet_init(
config.enableThreading ? 1 : 0,
config.enableAOT ? 1 : 0
);

if (result !== 0) {
throw new Error('Failed to initialize .NET runtime');
}

// Load main assembly
await this.loadMainAssembly();
},

// Load the main assembly
async loadMainAssembly() {
const assemblyPath = `${this.config.assemblyRoot}/${this.config.mainAssemblyName}`;
const response = await fetch(assemblyPath);
const assemblyData = await response.arrayBuffer();

// Load the assembly into the runtime
const dataPtr = this.instance.exports.malloc(assemblyData.byteLength);
new Uint8Array(this.instance.exports.memory.buffer).set(
new Uint8Array(assemblyData),
dataPtr
);

const result = this.instance.exports.dotnet_load_assembly(
dataPtr,
assemblyData.byteLength
);

if (result !== 0) {
throw new Error('Failed to load main assembly');
}

// Free the temporary buffer
this.instance.exports.free(dataPtr);
},

// Call a method in the .NET runtime
invokeMethod(typeName, methodName, ...args) {
// Convert arguments to appropriate format
const serializedArgs = JSON.stringify(args);
const argsPtr = this.allocateString(serializedArgs);

// Call the method
const resultPtr = this.instance.exports.dotnet_invoke_method(
this.allocateString(typeName),
this.allocateString(methodName),
argsPtr
);

// Get the result
const result = this.readString(resultPtr);

// Clean up
this.instance.exports.free(argsPtr);

return JSON.parse(result);
},

// Helper: Allocate a string in WASM memory
allocateString(str) {
const bytes = new TextEncoder().encode(str);
const ptr = this.instance.exports.malloc(bytes.length + 1);
new Uint8Array(this.instance.exports.memory.buffer).set(bytes, ptr);
new Uint8Array(this.instance.exports.memory.buffer)[ptr + bytes.length] = 0; // Null terminator
return ptr;
},

// Helper: Read a string from WASM memory
readString(ptr) {
if (ptr === 0) return null;

const memory = new Uint8Array(this.instance.exports.memory.buffer);
let end = ptr;
while (memory[end] !== 0) end++;

const bytes = memory.slice(ptr, end);
return new TextDecoder().decode(bytes);
}
};
97 changes: 97 additions & 0 deletions modules/mono/wasm/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body {
margin: 0;
padding: 0;
background-color: #333;
}
#canvas {
width: 100vw;
height: 100vh;
display: block;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-family: sans-serif;
}
.progress {
width: 300px;
height: 20px;
background-color: #444;
border-radius: 10px;
overflow: hidden;
margin-top: 10px;
}
.progress-bar {
width: 0%;
height: 100%;
background-color: #738bd7;
transition: width 0.3s ease;
}
</style>
</head>
<body>
<canvas id="canvas" tabindex="1"></canvas>
<div id="loading">
<div>Loading Godot C# Game...</div>
<div class="progress">
<div class="progress-bar" id="progress"></div>
</div>
</div>

<script src="dotnet.js"></script>
<script src="godot.js"></script>
<script>
const canvas = document.getElementById('canvas');
const loading = document.getElementById('loading');
const progress = document.getElementById('progress');

// Configure .NET runtime
const dotnetConfig = {
mainAssemblyName: '${assembly_name}',
assemblyRoot: '${assembly_root}',
assets: ${assets_list},
heapSize: ${heap_size},
enableThreading: ${enable_threading},
enableAOT: ${enable_aot}
};

// Configure Godot engine
const godotConfig = {
canvas: canvas,
canvasResizePolicy: 'project',
executable: 'game',
mainPack: '${main_pack}',
locale: '${locale}',
args: [],
onProgress: function(current, total) {
progress.style.width = (current / total * 100) + '%';
},
onLoad: function() {
loading.style.display = 'none';
}
};

// Initialize both runtimes
Promise.all([
dotnetRuntime.init(dotnetConfig),
godotRuntime.init(godotConfig)
]).then(() => {
// Start the game
godotRuntime.start();
}).catch((error) => {
console.error('Failed to initialize:', error);
loading.innerHTML = '<div style="color: red">Failed to load the game. Please check console for details.</div>';
});
</script>
</body>
</html>
Loading

0 comments on commit 387b6a3

Please sign in to comment.