forked from godotengine/godot
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
mono/wasm: Initial WebAssembly support for C# projects
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
Showing
12 changed files
with
754 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.