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

chore: clean recommit

This commit is contained in:
tiger tiger tiger 2026-07-07 23:29:25 +02:00
commit 8739e3b347
134 changed files with 15442 additions and 10648 deletions

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

@ -0,0 +1,86 @@
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.
/// </summary>
public partial class Host
{
public const int screenWidth = 800;
public const int screenHeight = 450;
// Examples discovered from the assembly, minus desktop-only ones (see ExampleRegistry).
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);
SetExample(_examples[0].Name);
}
/// <summary>Render one frame of the current example (called every requestAnimationFrame tick).</summary>
[JSExport]
public static void UpdateFrame()
{
try
{
_current?.Update();
}
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}");
_current = null;
}
}
/// <summary>Switch the active example by name (called from the nav dropdown).</summary>
[JSExport]
public static void SetExample(string name)
{
var next = _examples.Find(e => e.Name == name);
if (next == null)
{
return;
}
try
{
_current?.Unload();
_current = next;
_current.Init();
}
catch (Exception ex)
{
Console.WriteLine($"[Examples.Web] '{next.Name}' failed to initialize: {ex}");
_current = null;
}
}
/// <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);
}
}

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

@ -0,0 +1,118 @@
# Examples/Web — raylib-cs in the browser (WebAssembly)
A small browser app that runs raylib examples in the browser via WebAssembly, with a dropdown to
switch between them. It exists to **prove the `Raylib-cs` NuGet package's `browser-wasm` support
works end-to-end**: the package's `buildTransitive/Raylib-cs.targets` automatically links the
shipped `runtimes/browser-wasm/native/raylib.a` (and adds `-sUSE_GLFW=3`) into the .NET wasm
runtime. The browser host lives in `Examples/Web`, while `Examples.csproj` remains the single
examples project.
The browser-wasm configuration is enabled only when publishing `Examples` with
`RuntimeIdentifier=browser-wasm`, so normal solution builds do not require 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'
```
your installed `wasm-tools` workload (Binaryen) is out of sync with the SDK — emscripten passes a
feature flag the bundled `wasm-opt` doesn't understand (commonly caused by a stale workload band
or a conflicting system EMSDK on PATH/`$EMSDK`). Proper fix:
```bash
dotnet workload update
```
`Examples.csproj` defaults browser-wasm publishes to unoptimized native linking so the standard
publish command works on affected local toolchains. A fully optimized publish can override those
MSBuild properties after updating the workload/toolchain.
## 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 browser host keeps raylib's internal render buffer fixed to `800x450` and applies display
scaling in CSS. Use the **Scale** dropdown (or `?scale=` query param) to choose behavior:
- `native` (default): exact `800x450` CSS pixels (1x), no resizing.
- `integer`: largest whole-number multiple that fits the viewport, centered with letterboxing.
Best for pixel-perfect presentation.
- `fit`: fills available viewport while preserving aspect ratio (can be fractional, less crisp on
some DPI/zoom combinations).
Scaling is computed in **device pixels first** and then converted back to CSS pixels using current
`devicePixelRatio`, which decouples the modes from needing a specific browser zoom level on
125%/150% OS scale displays.
`main.js` also shows runtime diagnostics in the status bar (`DPR`, CSS size, backing size, scale)
to help debug OS scaling / browser zoom behavior across monitors.
### Manual validation matrix (expected behavior)
- OS scale `100%`, browser zoom `100%`: `integer` should appear crisp and stable (`Scale 1.00` or
higher if viewport allows).
- OS scale `125%` and `150%`, browser zoom `100%`: `integer` should stay crisp (no subpixel CSS
scaling); perceived physical size changes with OS scale are expected.
- Browser zoom `80%`, `100%`, `125%`: `integer` should continue using whole-number scaling;
`fit` may show softening at non-integer effective scales.
- `native` mode should always report `CSS 800x450` and `Scale 1.00`.
- During window resize, there should be no frame-by-frame jitter because scale updates run on
resize/mode changes, not per animation frame.
## 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 and selects the default example (`main.js` runs it via
`runMain()`).
- `main.js` binds the page `<canvas>` to the runtime, then calls `Host.UpdateFrame()` every
`requestAnimationFrame` tick. `UpdateFrame`, `SetExample`, and `GetExampleNames` are `[JSExport]`.
- Each base example implements `IExample` (`Init` / `Update` / `Unload`) in `#if BROWSER` partials.
The host owns the window;
examples never call `InitWindow`/`CloseWindow`.
## Adding more examples
For a new desktop example (`../Core/...`, `../Shaders/...`, etc.), add a browser partial
(`*.Browser.cs`) that implements `IExample` and mirrors the split of its monolithic `Main`:
```
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> }
```
Then add `new YourExample()` to the `Examples` list in `Host.cs`.
Examples that load files (`resources/...`) also need those assets in the wasm virtual filesystem.
`Examples.csproj` bundles `../resources/` into the browser app when publishing with
`RuntimeIdentifier=browser-wasm`.

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

@ -0,0 +1,70 @@
<!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; }
</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="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>

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

@ -0,0 +1,157 @@
import { dotnet } from './_framework/dotnet.js'
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
normalizeScaleMode,
computeDisplaySize
} from './scaleUtils.js'
const status = document.getElementById('status');
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`;
// Center canvas inside viewport, leaving letterboxing around it.
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 + default example Init(). Must come after the canvas is bound.
await runMain();
applyDisplayScale();
// Populate the navigation dropdown from the registered examples.
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', () => Host.SetExample(select.value));
setScaleMode(scaleMode, false);
// Drive raylib one frame per animation tick (never block the browser).
function mainLoop() {
Host.UpdateFrame();
requestAnimationFrame(mainLoop);
}
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);
});
});