Watch
2
0
Fork
You've already forked raylib-cs
0

feat: backporting existing raylib-cs examples and new official raylib examples to WASM, adopting raylib's original code style

This commit is contained in:
tiger tiger tiger 2026-07-17 08:22:54 +02:00
commit f804ab7773
234 changed files with 38997 additions and 10578 deletions

132
Examples/Web/Host.cs Normal file
View file

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices.JavaScript;
namespace Examples.Web;
/// <summary>
/// Browser host: owns the single raylib window and the "current" example. The frame loop is
/// driven from JavaScript (main.js, via requestAnimationFrame) since a blocking C# loop would
/// freeze the page. Methods marked [JSExport] are called from main.js.
///
/// <para>
/// Every switch applies <see cref="IExample.TargetFps"/> and resets the cursor to visible.
/// Cursor hiding, <see cref="IExample.ConfigFlags"/>, and per-example window size are not
/// honored in the browser; the canvas stays 800x450 and the pointer stays visible.
/// </para>
/// </summary>
public partial class Host
{
public const int screenWidth = 800;
public const int screenHeight = 450;
private static readonly List<IExample> _examples = new(ExampleRegistry.BrowserExamples);
private static IExample _current;
public static void Main()
{
InitWindow(screenWidth, screenHeight, "raylib-cs web examples");
SetTargetFPS(60);
// main.js selects the first example, so its init failures surface through SetExample.
}
/// <summary>Render one frame of the current example; false if it threw and was torn down.</summary>
[JSExport]
public static bool UpdateFrame()
{
if (_current == null)
{
return true;
}
try
{
_current.Update();
return true;
}
catch (Exception ex)
{
// Don't let one misbehaving example kill the whole page; log and stop driving it.
Console.WriteLine($"[Examples.Web] '{_current.Name}' threw during Update: {ex}");
TryUnload(_current);
_current = null;
return false;
}
}
/// <summary>Switch the active example by name; false on unknown name or failed Init.</summary>
[JSExport]
public static bool SetExample(string name)
{
var next = _examples.Find(e => e.Name == name);
if (next == null)
{
return false;
}
if (_current != null)
{
TryUnload(_current);
_current = null;
}
// Reset the baseline; an example may hide/lock the cursor from its own Update(), and
// CursorDisabled/CursorHidden are deliberately never applied in the browser.
EnableCursor();
ShowCursor();
SetTargetFPS(next.TargetFps);
try
{
next.Init();
_current = next;
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[Examples.Web] '{next.Name}' failed to initialize: {ex}");
TryUnload(next);
return false;
}
}
/// <summary>Target FPS of the current example; main.js paces the frame loop with it.</summary>
[JSExport]
public static int GetCurrentTargetFps()
{
return _current?.TargetFps ?? 60;
}
/// <summary>Newline-separated example names, used to populate the nav dropdown.</summary>
[JSExport]
public static string GetExampleNames()
{
return string.Join("\n", _examples.ConvertAll(e => e.Name));
}
/// <summary>
/// Map browser CSS mouse coordinates to the fixed 800x450 framebuffer when the canvas
/// is CSS-scaled (integer/fit/native display modes in main.js).
/// </summary>
[JSExport]
public static void SetMouseScaleFromDisplay(float scaleX, float scaleY)
{
SetMouseOffset(0, 0);
SetMouseScale(scaleX, scaleY);
}
/// <summary>Best-effort Unload that never lets a failure cascade.</summary>
private static void TryUnload(IExample example)
{
try
{
example.Unload();
}
catch (Exception ex)
{
Console.WriteLine($"[Examples.Web] '{example.Name}' threw during Unload: {ex}");
}
}
}

91
Examples/Web/README.md Normal file
View file

@ -0,0 +1,91 @@
# Examples/Web — raylib-cs in the browser (WebAssembly)
Runs the raylib examples in the browser via WebAssembly, with a dropdown to switch between them.
It proves the `Raylib-cs` NuGet package's `browser-wasm` support end-to-end: the package's
`buildTransitive` targets link the shipped `raylib.a` into the .NET wasm runtime.
The browser-wasm configuration only activates when publishing with
`RuntimeIdentifier=browser-wasm`; normal solution builds don't need the `wasm-tools` workload.
## Prerequisites
- .NET 10 SDK
- `dotnet workload install wasm-tools`
- The `Raylib-cs` package matching `$(RaylibCsVersion)` (see `Directory.Build.props`) — from
nuget.org, or built locally into the repo's `./nuget` feed (`dotnet pack Raylib-cs -c Release --output nuget`).
## Build
```bash
dotnet publish Examples -f net10.0 -r browser-wasm -c Release
# -> Examples/bin/Release/net10.0/browser-wasm/AppBundle/
```
### Toolchain caveat
If the link step fails with `wasm-opt: Unknown option '--enable-bulk-memory-opt'`, the installed
`wasm-tools` workload is out of sync with the SDK (stale workload band or a system EMSDK on
PATH); fix with `dotnet workload update`. As a safety net, `Examples.csproj` defaults
browser-wasm publishes to unoptimized native linking; an optimized publish can override those
properties.
## Run
WebAssembly must be served over HTTP (not `file://`):
```bash
dotnet serve -d Examples/bin/Release/net10.0/browser-wasm/AppBundle # dotnet tool install -g dotnet-serve
# or: npx http-server Examples/bin/Release/net10.0/browser-wasm/AppBundle
```
Open the printed URL and use the **Example** dropdown to switch examples.
## Canvas scaling modes
The render buffer stays fixed at `800x450`; display scaling is CSS-only. Pick with the **Scale**
dropdown (or `?scale=` query param):
- `native` (default): exact `800x450` CSS pixels.
- `integer`: largest whole-number multiple that fits, centered with letterboxing (pixel-perfect).
- `fit`: fills the viewport preserving aspect ratio (can be fractional, less crisp).
Scaling is computed in device pixels and converted back to CSS via `devicePixelRatio`, so the
modes behave consistently across OS scale and browser zoom. The status bar shows the live numbers
(`DPR`, CSS size, backing size, scale): `integer` should stay crisp at any OS scale or zoom, and
`native` should always report `Scale 1.00`.
## How it works
A browser can't run raylib's blocking `while (!WindowShouldClose())` loop (it would freeze the
page), so frames are driven from JavaScript:
- `Host.Main()` calls `InitWindow` once; `main.js` then populates the dropdown and selects the
first example via `Host.SetExample`, so init failures surface in the on-page error banner.
- `main.js` binds the page `<canvas>` to the runtime and calls `Host.UpdateFrame()` from a
`requestAnimationFrame` loop paced to the current example's `TargetFps` (raylib's own limiter
busy-waits and would peg the main thread). The `Host` methods it calls are `[JSExport]`.
- On switch, `Host.SetExample` unloads the previous example, resets the cursor to visible, and
applies the next `TargetFps`. Cursor hiding and `ConfigFlags` are not honored in the browser.
If `Init` or `Update` throws, the example is unloaded and an error banner is shown.
- Each example is a single `.cs` file implementing `IExample` (`Init` / `Update` / `Unload`); the
host owns the window, so examples never call `InitWindow`/`CloseWindow`. Platform differences
(e.g. GLSL 100 vs 330) are handled inline with `#if BROWSER` guards, not separate files.
## Adding more examples
Examples are **auto-discovered by reflection** (`ExampleRegistry.DiscoverAll`) — no list to edit.
Drop a new `.cs` file in the matching category folder implementing `IExample`, splitting the
original monolithic `Main` as:
```
Main() { <setup>; while(!WindowShouldClose()){ <body> } <cleanup> }
-> Init() { <setup, minus InitWindow/SetTargetFPS> } // loop-spanning locals become fields
Update(){ <body> } // keep BeginDrawing..EndDrawing
Unload(){ <cleanup, minus CloseWindow> }
```
Keep the standalone `static Main()` as a thin driver so the example still runs on its own.
If an example can't run on single-threaded wasm, add its type to `DesktopExcludedFromBrowser` in
`ExampleRegistry.cs`; `BrowserOnly` is the inverse list. Assets under `resources/` are bundled
into the wasm virtual filesystem automatically.

79
Examples/Web/index.html Normal file
View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>raylib-cs — browser examples</title>
<link rel="icon" type="image/x-icon" href="./favicon.ico"/>
<link rel="modulepreload" href="./main.js"/>
<link rel="modulepreload" href="./_framework/dotnet.js"/>
<style>
html, body { height: 100%; }
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 0.75rem;
box-sizing: border-box;
background: #1d1d1d;
color: #eaeaea;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
h1 { font-size: 1.1rem; font-weight: 600; }
#toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; }
#examples { font-size: 1rem; padding: .3rem .5rem; }
#scaleMode { font-size: 0.95rem; padding: .2rem .4rem; }
#viewport {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
background: #111;
}
canvas {
border: 1px solid #444;
display: block;
flex: 0 0 auto;
width: 800px;
height: 450px;
max-width: none;
max-height: none;
image-rendering: pixelated;
image-rendering: crisp-edges;
background: #000;
}
#status { color: #888; font-size: .85rem; margin: 0; }
#error {
color: #ff6b6b;
background: #3a1d1d;
border: 1px solid #6b2b2b;
border-radius: 4px;
padding: .4rem .6rem;
font-size: .9rem;
}
</style>
</head>
<body>
<h1>raylib-cs running in the browser (WebAssembly)</h1>
<div id="toolbar">
<label for="examples">Example:&nbsp;</label>
<select id="examples"><option>loading…</option></select>
<label for="scaleMode">&nbsp;Scale:&nbsp;</label>
<select id="scaleMode">
<option value="integer">Integer</option>
<option value="native">Native (1x)</option>
<option value="fit">Fit</option>
</select>
</div>
<div id="error" hidden>This example crashed — see the browser console for details. Select another example to continue.</div>
<div id="viewport">
<canvas id="canvas" width="800" height="450" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
</div>
<div id="status">Initializing .NET + raylib…</div>
<script type="module" src="./main.js"></script>
</body>
</html>

176
Examples/Web/main.js Normal file
View file

@ -0,0 +1,176 @@
import { dotnet } from './_framework/dotnet.js'
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
normalizeScaleMode,
computeDisplaySize
} from './scaleUtils.js'
const status = document.getElementById('status');
const errorBanner = document.getElementById('error');
const select = document.getElementById('examples');
const canvas = document.getElementById('canvas');
const viewport = document.getElementById('viewport');
const scaleModeSelect = document.getElementById('scaleMode');
function readScaleModeFromQuery() {
const params = new URLSearchParams(window.location.search);
return normalizeScaleMode(params.get('scale'));
}
function writeScaleModeToQuery(mode) {
const params = new URLSearchParams(window.location.search);
params.set('scale', mode);
const query = params.toString();
const nextUrl = query.length > 0 ? `${window.location.pathname}?${query}` : window.location.pathname;
window.history.replaceState(null, '', nextUrl);
}
let scaleMode = readScaleModeFromQuery();
let Host = null;
function ensureBackingBufferSize() {
if (canvas.width !== CANVAS_WIDTH) {
canvas.width = CANVAS_WIDTH;
}
if (canvas.height !== CANVAS_HEIGHT) {
canvas.height = CANVAS_HEIGHT;
}
}
function updateScaleDiagnostics(displayScale, cssWidth, cssHeight, deviceWidth, deviceHeight, dpr) {
status.textContent =
`Running. Example: ${scaleMode} scaling | DPR ${dpr.toFixed(2)} | ` +
`CSS ${cssWidth.toFixed(2)}x${cssHeight.toFixed(2)} | ` +
`Device ${deviceWidth}x${deviceHeight} | Backing ${canvas.width}x${canvas.height} | ` +
`Scale ${displayScale.toFixed(2)}`;
}
function syncMouseScale() {
if (!Host) {
return;
}
const cssWidth = canvas.clientWidth;
const cssHeight = canvas.clientHeight;
if (cssWidth <= 0 || cssHeight <= 0) {
return;
}
// Map CSS/display mouse coords to the fixed backing buffer (canvas.width/height).
const scaleX = canvas.width / cssWidth;
const scaleY = canvas.height / cssHeight;
Host.SetMouseScaleFromDisplay(scaleX, scaleY);
}
function applyDisplayScale() {
ensureBackingBufferSize();
const viewportWidth = viewport.clientWidth || CANVAS_WIDTH;
const viewportHeight = viewport.clientHeight || CANVAS_HEIGHT;
const dprRaw = window.devicePixelRatio ?? 1;
const display = computeDisplaySize(scaleMode, viewportWidth, viewportHeight, dprRaw);
canvas.style.width = `${display.cssWidth}px`;
canvas.style.height = `${display.cssHeight}px`;
viewport.style.justifyContent = 'center';
viewport.style.alignItems = 'center';
syncMouseScale();
updateScaleDiagnostics(
display.scale,
display.cssWidth,
display.cssHeight,
display.deviceWidth,
display.deviceHeight,
display.dpr
);
}
function setScaleMode(mode, updateQuery) {
const nextMode = normalizeScaleMode(mode);
if (nextMode === scaleMode && !updateQuery) {
return;
}
scaleMode = nextMode;
if (scaleModeSelect) {
scaleModeSelect.value = scaleMode;
}
if (updateQuery) {
writeScaleModeToQuery(scaleMode);
}
applyDisplayScale();
}
if (scaleModeSelect) {
scaleModeSelect.value = scaleMode;
scaleModeSelect.addEventListener('change', () => {
setScaleMode(scaleModeSelect.value, true);
});
}
window.addEventListener('resize', applyDisplayScale);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', applyDisplayScale);
}
applyDisplayScale();
const { getAssemblyExports, getConfig, runMain } = await dotnet
.withDiagnosticTracing(false)
.create();
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
Host = exports.Examples.Web.Host;
// raylib's GLFW/emscripten backend renders into this canvas.
dotnet.instance.Module['canvas'] = canvas;
// Runs Host.Main() -> InitWindow. Must come after the canvas is bound.
await runMain();
applyDisplayScale();
let targetFps = 60;
function selectExample(name) {
const ok = Host.SetExample(name);
targetFps = Host.GetCurrentTargetFps();
errorBanner.hidden = ok;
}
select.innerHTML = '';
for (const name of Host.GetExampleNames().split('\n').filter(n => n.length > 0)) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
select.appendChild(opt);
}
select.addEventListener('change', () => selectExample(select.value));
selectExample(select.value);
setScaleMode(scaleMode, false);
// Drive raylib one frame per animation tick, paced to the example's target FPS here rather
// than by raylib's limiter (which busy-waits in EndDrawing and would peg the main thread).
let nextFrameTime = 0;
function mainLoop(timestamp) {
requestAnimationFrame(mainLoop);
if (timestamp < nextFrameTime) {
return;
}
// Schedule one interval ahead; resync to now if we've fallen behind (hidden tab).
nextFrameTime = Math.max(nextFrameTime + 1000 / targetFps, timestamp);
if (!Host.UpdateFrame()) {
errorBanner.hidden = false;
}
}
requestAnimationFrame(mainLoop);

View file

@ -0,0 +1,67 @@
export const CANVAS_WIDTH = 800;
export const CANVAS_HEIGHT = 450;
export const SCALE_MODES = new Set(['integer', 'native', 'fit']);
export const DEFAULT_SCALE_MODE = 'native';
export function normalizeScaleMode(mode) {
if (!mode) {
return DEFAULT_SCALE_MODE;
}
const normalized = String(mode).toLowerCase();
return SCALE_MODES.has(normalized) ? normalized : DEFAULT_SCALE_MODE;
}
export function computeDisplaySize(mode, viewportWidth, viewportHeight, dpr = 1) {
const safeDpr = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return {
cssWidth: CANVAS_WIDTH / safeDpr,
cssHeight: CANVAS_HEIGHT / safeDpr,
deviceWidth: CANVAS_WIDTH,
deviceHeight: CANVAS_HEIGHT,
scale: 1,
dpr: safeDpr
};
}
// Convert viewport from CSS pixels to device pixels first so scaling is DPI/zoom aware.
const viewportDeviceWidth = Math.max(1, Math.floor(viewportWidth * safeDpr));
const viewportDeviceHeight = Math.max(1, Math.floor(viewportHeight * safeDpr));
let deviceWidth;
let deviceHeight;
let scale;
if (mode === 'native') {
// 1 source pixel -> 1 physical device pixel.
scale = 1;
deviceWidth = CANVAS_WIDTH;
deviceHeight = CANVAS_HEIGHT;
} else if (mode === 'fit') {
const fitRatio = Math.min(viewportDeviceWidth / CANVAS_WIDTH, viewportDeviceHeight / CANVAS_HEIGHT);
const safeRatio = Number.isFinite(fitRatio) && fitRatio > 0 ? fitRatio : 1;
scale = safeRatio;
deviceWidth = Math.max(1, Math.floor(CANVAS_WIDTH * safeRatio));
deviceHeight = Math.max(1, Math.floor(CANVAS_HEIGHT * safeRatio));
} else {
// integer mode
const integerScale = Math.max(1, Math.floor(Math.min(
viewportDeviceWidth / CANVAS_WIDTH,
viewportDeviceHeight / CANVAS_HEIGHT
)));
scale = integerScale;
deviceWidth = CANVAS_WIDTH * integerScale;
deviceHeight = CANVAS_HEIGHT * integerScale;
}
return {
cssWidth: deviceWidth / safeDpr,
cssHeight: deviceHeight / safeDpr,
deviceWidth,
deviceHeight,
scale,
dpr: safeDpr
};
}

View file

@ -0,0 +1,92 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
normalizeScaleMode,
computeDisplaySize
} from './scaleUtils.js';
describe('normalizeScaleMode', () => {
it('returns native for null, undefined, and empty string', () => {
assert.equal(normalizeScaleMode(null), 'native');
assert.equal(normalizeScaleMode(undefined), 'native');
assert.equal(normalizeScaleMode(''), 'native');
});
it('normalizes allowlisted values case-insensitively', () => {
assert.equal(normalizeScaleMode('INTEGER'), 'integer');
assert.equal(normalizeScaleMode('Fit'), 'fit');
assert.equal(normalizeScaleMode('native'), 'native');
});
it('returns native for unknown values', () => {
assert.equal(normalizeScaleMode('stretch'), 'native');
assert.equal(normalizeScaleMode('bogus'), 'native');
});
});
describe('computeDisplaySize', () => {
it('native mode uses 1:1 device pixels at scale 1', () => {
const result = computeDisplaySize('native', 1200, 800, 1);
assert.equal(result.scale, 1);
assert.equal(result.deviceWidth, CANVAS_WIDTH);
assert.equal(result.deviceHeight, CANVAS_HEIGHT);
assert.equal(result.cssWidth, CANVAS_WIDTH);
assert.equal(result.cssHeight, CANVAS_HEIGHT);
assert.equal(result.dpr, 1);
});
it('integer mode scales by floored ratio with minimum scale 1', () => {
const doubled = computeDisplaySize('integer', 1600, 900, 1);
assert.equal(doubled.scale, 2);
assert.equal(doubled.deviceWidth, CANVAS_WIDTH * 2);
assert.equal(doubled.deviceHeight, CANVAS_HEIGHT * 2);
const single = computeDisplaySize('integer', 900, 500, 1);
assert.equal(single.scale, 1);
assert.equal(single.deviceWidth, CANVAS_WIDTH);
assert.equal(single.deviceHeight, CANVAS_HEIGHT);
});
it('fit mode scales proportionally and keeps device dimensions at least 1', () => {
const result = computeDisplaySize('fit', 400, 225, 1);
assert.equal(result.scale, 0.5);
assert.equal(result.deviceWidth, 400);
assert.equal(result.deviceHeight, 225);
});
it('fit mode falls back to scale 1 for zero or negative viewport', () => {
const zero = computeDisplaySize('fit', 0, 0, 1);
assert.equal(zero.scale, 1);
assert.equal(zero.deviceWidth, CANVAS_WIDTH);
assert.equal(zero.deviceHeight, CANVAS_HEIGHT);
const negative = computeDisplaySize('fit', -100, -50, 1);
assert.equal(negative.scale, 1);
assert.equal(negative.deviceWidth, CANVAS_WIDTH);
assert.equal(negative.deviceHeight, CANVAS_HEIGHT);
});
it('applies dpr to viewport before scaling and converts css size back', () => {
const result = computeDisplaySize('native', 400, 225, 2);
assert.equal(result.dpr, 2);
assert.equal(result.deviceWidth, CANVAS_WIDTH);
assert.equal(result.deviceHeight, CANVAS_HEIGHT);
assert.equal(result.cssWidth, CANVAS_WIDTH / 2);
assert.equal(result.cssHeight, CANVAS_HEIGHT / 2);
});
it('treats invalid dpr as 1', () => {
assert.equal(computeDisplaySize('native', 800, 450, 0).dpr, 1);
assert.equal(computeDisplaySize('native', 800, 450, NaN).dpr, 1);
assert.equal(computeDisplaySize('native', 800, 450, Infinity).dpr, 1);
});
});