Remove the WebGL GL backend: web/src/gl.js, gl-pixels.js, tests/browser-gl

Guest GL in the browser goes through Mesa (gex_wgpu over WebGPU) only. The
GLES-to-WebGL2 passthrough (GlDevice) was left only as the gles-gears demo's
default; that demo now uses MesaGlDevice like every other GL path. Comments
that pointed at gl.js now point at mesa-gl.js / gl-proto.js. The Wayland
compositor's own WebGL2 context is next (port to WebGPU, stream E).

Browser NT 49/49; vite build ok.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PeRutwFgZ66XpxjasKrPn1
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-10-05 12:20:35 -04:00
1 parent 4aed582940
commit 7230bd246e
10 files changed
+14 -944

No files matched your search

+1 -1
View File
@@ -2376,7 +2376,7 @@ export class Gex {
}
/**
* Give the guest a graphics device. `gl` is a `GlDevice` from web/src/gl.js.
* Give the guest a graphics device. `gl` is a `MesaGlDevice` from web/src/mesa-gl.js.
* Off by default, like the network. Best set before `load`, since the device
* node is made while the filesystem is built; a later call still works.
*/
+3 -3
View File
@@ -1,5 +1,5 @@
// The guest's GL device, reached from a Worker. The WebGL `GlDevice`
// (web/src/gl.js) is on the page -- it owns the canvas -- while the drawing
// The guest's GL device, reached from a Worker. The `MesaGlDevice`
// (web/src/mesa-gl.js) is on the page -- it owns the canvas -- while the drawing
// guest is a Worker. `gl_submit` is a wasm import called synchronously from
// inside an ioctl, so the
// call crosses a `SharedArrayBuffer` with `Atomics.wait`: the Worker copies its
@@ -25,7 +25,7 @@ const IDLE = 0;
const REQUEST = 1;
const REPLY = 2;
// Must match guest/gl/gexgl.c and web/src/gl.js.
// Must match guest/gl/gexgl.c and web/src/gl-proto.js.
const HEADER_BYTES = 32;
const CMD_CAPACITY = 32 * 1024 * 1024;
const REPLY_CAPACITY = 32 * 1024 * 1024;
+2 -2
View File
@@ -1,8 +1,8 @@
//! The guest's graphics device in a browser: `/dev/gex-gl`.
//!
//! Same protocol as the native device (the guest's `libGLESv2.so.2` is one
//! binary), executed here by the page's WebGL 2 (OpenGL ES 3.0). Page side is
//! `web/src/gl.js`. The import carries an address into guest memory rather than
//! binary), executed here by Mesa (gex_wgpu over WebGPU). Page side is
//! `web/src/mesa-gl.js`. The import carries an address into guest memory rather than
//! a copy, so a frame costs one call out of wasm however many GL calls it holds.
use gex_kernel::errno::{Errno, SysResult};
-130
View File
@@ -1,130 +0,0 @@
<!doctype html>
<meta charset="utf-8">
<title>gex: a guest's OpenGL on WebGL</title>
<!--
The smallest page that runs a guest and gives it a canvas.
Nothing here is about presentation; it exists so a headless browser can
report what happened. The demo page in web/ is the one meant for people.
What it checks is the whole path: an aarch64 binary that calls eglInitialize
and glDrawArrays, translated to wasm, whose GL library marshals into its own
memory, handed to this page one batch at a time, executed on WebGL 2, drawn
on the canvas below.
-->
<style>
body { background: #14161c; color: #c8ccd4; font: 13px ui-monospace, monospace; }
canvas { border: 1px solid #333; image-rendering: pixelated; }
#log { white-space: pre-wrap; }
</style>
<canvas id="screen" width="320" height="240"></canvas>
<pre id="log"></pre>
<script type="module">
import { Gex, Env } from './gex.mjs';
import { GlDevice } from './src/gl.js';
// The DOM is scraped by the runner, so everything it needs goes in one
// element joined by bars -- newlines do not survive --dump-dom intact.
const lines = [];
const say = (s) => {
lines.push(s);
document.getElementById('log').textContent = lines.join('|');
};
/// Tell the runner it is over. A page cannot be scraped reliably while it is
/// still working -- the guest takes as long as it takes -- so it reports
/// rather than being watched.
const finish = () => {
// A plain GET, not `sendBeacon`: that one POSTs, and a report that silently
// goes to a method the server does not answer looks exactly like a page
// that never finished.
const body = encodeURIComponent(lines.join('|'));
return fetch(`/result?log=${body}`, { keepalive: true }).catch(() => {});
};
const prog = new URLSearchParams(location.search).get('prog') || 'gles-triangle';
/** Collects what the guest writes, and answers reads with nothing. */
class Collect extends Env {
constructor() { super(); this.text = ''; this.partial = ''; }
write(fd, bytes) {
const s = new TextDecoder().decode(bytes);
this.text += s;
// Reported as it arrives rather than at the end: if the guest stops
// half way, what it managed to say is the only clue there is.
this.partial += s;
let nl;
while ((nl = this.partial.indexOf('\n')) >= 0) {
const line = this.partial.slice(0, nl).trim();
this.partial = this.partial.slice(nl + 1);
if (line) say(line);
}
}
now() { return BigInt(Date.now()) * 1000000n; }
}
async function bytes(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return new Uint8Array(await r.arrayBuffer());
}
try {
const canvas = document.getElementById('screen');
const gl = new GlDevice(canvas);
if (!gl.ok) throw new Error('no WebGL 2 in this browser');
say(`webgl ${gl.describe()}`);
const env = new Collect();
const gex = new Gex(env);
// Before load: the device node is made while the guest's filesystem is.
gex.useGl(gl);
// The guest's own filesystem: its loader and the GL driver it links
// against. musl searches /usr/lib by default, so nothing has to tell it
// where these are.
say('fetching');
const files = {
'/lib/ld-musl-aarch64.so.1': await bytes('/fs/ld-musl-aarch64.so.1'),
'/usr/lib/libGLESv2.so.2': await bytes('/fs/libGLESv2.so.2'),
'/usr/lib/libEGL.so.1': await bytes('/fs/libEGL.so.1'),
};
const args = [`/${prog}`, ...(prog === 'gles-gears' ? ['12'] : [])];
say('loading');
const entry = await gex.load(
await bytes('/gex/gex_browser.wasm'),
await bytes(`/fs/${prog}.wasm`),
await bytes(`/fs/${prog}`),
files,
args,
{},
);
say(`entry ${entry}`);
const status = await gex.run(entry);
say(`exit ${status}`);
// The checks, which differ per program because what they draw does.
let ok = false;
if (prog === 'gles-triangle') {
ok = /inside\s+0 255 0/.test(env.text) && /outside\s+0 0 255/.test(env.text);
} else {
const m = env.text.match(/covered (\d+)%/);
const covered = m ? Number(m[1]) : -1;
ok = /gl error 0/.test(env.text) && covered >= 5 && covered <= 60;
}
// And that something actually landed on the canvas, which is the part the
// guest's own output cannot tell us.
const px = new Uint8Array(4);
gl.gl.readPixels(canvas.width >> 1, canvas.height >> 1, 1, 1,
gl.gl.RGBA, gl.gl.UNSIGNED_BYTE, px);
say(`canvas centre ${px[0]} ${px[1]} ${px[2]}`);
const drawn = px[0] + px[1] + px[2] > 0;
say(`RESULT ${ok && drawn ? 'ok' : 'failed'}`);
} catch (e) {
say(`error ${e && e.message ? e.message : e}`);
say('RESULT failed');
}
await finish();
</script>
-127
View File
@@ -1,127 +0,0 @@
#!/bin/sh
# The guest's OpenGL, on a real browser's WebGL.
#
# tests/browser-gl/run.sh [work-dir]
#
# Node cannot run this one. The other browser tests use node because what they
# check is the translated code, and V8 is V8 wherever it is; this checks that a
# guest's GL calls reach a canvas, and node has no canvas. So it drives
# headless Chromium and reads what the page printed.
#
# Two programs, both ordinary GLES2 built against the standard headers and
# linked against the usual sonames:
#
# gles-triangle a green triangle over a blue clear, checked pixel by pixel
# gles-gears the three gears, checked by how much of the frame they cover
#
# The whole point is that neither knows it is not talking to Mesa.
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
WORK="${1:-${TMPDIR:-/tmp}/gex-browser-gl}"
GEXC="$ROOT/target/release/gexc"
SHIM="$ROOT/target/wasm32-unknown-unknown/release/gex_browser.wasm"
CC="${GEX_CC:-$HOME/toolchains/aarch64-linux-musl-cross/bin/aarch64-linux-musl-gcc}"
MUSL="$(dirname "$CC")/../aarch64-linux-musl/lib/libc.so"
CHROME="${GEX_CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
for need in "$GEXC" "$CC" "$CHROME"; do
if [ -z "$need" ] || [ ! -x "$need" ]; then
echo "skip: missing $need"
exit 0
fi
done
# Built through the shim's own script, not plain cargo: the memory has to be
# imported, the globals rebased above the guest, and `__stack_pointer`
# exported so the page can move the shim's stack out of the guest's image. A
# module built without those loads and then fails on the first call.
echo "== building the browser shim"
"$ROOT/shims/browser/build.sh" >/dev/null 2>&1 || true
[ -f "$SHIM" ] || { echo "skip: the browser shim did not build"; exit 0; }
[ -d /usr/include/GLES2 ] || { echo "skip: no GLES2 headers"; exit 0; }
mkdir -p "$WORK/include"
cp -r /usr/include/EGL /usr/include/GLES2 /usr/include/KHR "$WORK/include/" 2>/dev/null || true
echo "== building the guest's GL driver"
"$ROOT/guest/gl/build.sh" "$WORK/driver" >/dev/null
echo "== building the programs"
mkdir -p "$WORK/root/lib" "$WORK/root/usr/lib"
cp "$MUSL" "$WORK/root/lib/ld-musl-aarch64.so.1"
cp "$WORK/driver/libGLESv2.so.2" "$WORK/driver/libEGL.so.1" "$WORK/root/usr/lib/"
for prog in gles-triangle gles-gears; do
"$CC" -O2 -o "$WORK/root/$prog" "$ROOT/tests/integration/programs/$prog.c" \
-I "$WORK/include" -L "$WORK/driver" -lEGL -lGLESv2 -lm
done
# One module per program. `--no-shared-memory` because the page's runtime
# declares a plain memory, and `--exhaustive` is not needed: the dispatch miss
# pulls in what the guest actually reaches.
echo "== translating"
for prog in gles-triangle gles-gears; do
"$GEXC" build --no-shared-memory "$WORK/root/$prog" -o "$WORK/$prog.wasm" >/dev/null
done
# Everything the page loads has to be same-origin, so it is served rather than
# opened from a file URL.
mkdir -p "$WORK/site/gex" "$WORK/site/src" "$WORK/site/fs"
cp "$SHIM" "$WORK/site/gex/gex_browser.wasm"
cp "$ROOT/shims/browser/js/gex.mjs" "$WORK/site/"
cp "$ROOT/web/src/gl.js" "$ROOT/web/src/gl-proto.js" "$WORK/site/src/"
for prog in gles-triangle gles-gears; do
cp "$WORK/$prog.wasm" "$WORK/root/$prog" "$WORK/site/fs/"
done
cp "$WORK/root/lib/ld-musl-aarch64.so.1" "$WORK/site/fs/"
cp "$WORK/driver/libGLESv2.so.2" "$WORK/driver/libEGL.so.1" "$WORK/site/fs/"
cp "$HERE/page.html" "$WORK/site/index.html"
port=$((18000 + $$ % 2000))
pass=0
fail=0
for prog in gles-triangle gles-gears; do
echo "== running $prog in Chromium"
out="$WORK/$prog.out"
rm -f "$out"
python3 "$HERE/serve.py" "$WORK/site" "$port" "$out" >/dev/null 2>&1 &
server=$!
for _ in $(seq 50); do
curl -fsS "http://127.0.0.1:$port/index.html" >/dev/null 2>&1 && break
sleep 0.1
done
# SwiftShader because no GPU here is reachable from the sandbox; the path
# through WebGL is the same either way. No virtual-time budget: the page
# says when it is finished, and this waits for it to.
timeout 600 "$CHROME" --headless --no-sandbox --disable-gpu \
--enable-unsafe-swiftshader \
"http://127.0.0.1:$port/index.html?prog=$prog" >/dev/null 2>&1 &
browser=$!
for _ in $(seq 600); do
[ -s "$out" ] && break
sleep 1
done
kill $browser 2>/dev/null || true
kill $server 2>/dev/null || true
wait $browser 2>/dev/null || true
wait $server 2>/dev/null || true
if [ ! -s "$out" ]; then
echo "FAIL $prog: the page never reported"
fail=$((fail + 1))
continue
fi
sed 's/^/ /' "$out"
if grep -q "^RESULT ok" "$out"; then
pass=$((pass + 1))
else
echo "FAIL $prog"
fail=$((fail + 1))
fi
port=$((port + 1))
done
echo "$pass passed, $fail failed"
[ "$fail" -eq 0 ]
-47
View File
@@ -1,47 +0,0 @@
"""A static server that also collects one result.
`--dump-dom` with a virtual-time budget is the obvious way to scrape a
headless page, and it is wrong for this one: the budget expires on wall-clock
idleness while the guest is still working, and what gets dumped is the page
half way through. The guest here takes tens of seconds -- translation, then
software rendering -- and how long is not predictable.
So the page says when it is done, by fetching `/result?...`, and the runner
waits for that to arrive. No polling of the DOM, no guessing at a timeout,
and a run that fails still reports because the failure is sent the same way.
"""
import http.server
import sys
import urllib.parse
ROOT = sys.argv[1]
PORT = int(sys.argv[2])
OUT = sys.argv[3]
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=ROOT, **kw)
def do_GET(self):
if self.path.startswith("/result"):
query = urllib.parse.urlparse(self.path).query
body = urllib.parse.parse_qs(query).get("log", [""])[0]
with open(OUT, "w", encoding="utf-8") as f:
f.write(body.replace("|", "\n"))
f.write("\n")
self.send_response(204)
self.end_headers()
return
super().do_GET()
# `sendBeacon` would POST; this answers both so a page reporting either
# way is heard.
do_POST = do_GET
def log_message(self, *a):
pass
http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
+7 -14
View File
@@ -413,15 +413,11 @@ const gears = {
blurb:
'An ordinary OpenGL ES 2 program. It includes the standard headers and links against ' +
'libEGL and libGLESv2 by their usual names -- and those libraries, inside the guest, are ' +
'marshallers: every call crosses to this page and runs on WebGL.',
'marshallers: every call crosses to this page and runs on Mesa (gex_wgpu) over WebGPU.',
network: false,
async enter(ctx) {
const query = new URLSearchParams(location.search);
// The Mesa path: same guest, same wire protocol, but st/mesa and the
// gex_wgpu Gallium driver below the decoder instead of the page's WebGL.
const mesaWebGpu = query.get('webgpu') === 'mesa' || query.get('gl') === 'mesa';
const { GlDevice } = await import('./gl.js');
// The canvas lives above the terminal while this demo is chosen, made here
// rather than in the markup so nothing else has to know the demo exists.
@@ -434,13 +430,10 @@ const gears = {
document.getElementById('terminal')?.before(wrap);
ctx.onLeave(() => wrap.remove());
let gl;
if (mesaWebGpu) {
gl = await (await import('./mesa-gl.js')).MesaGlDevice.create(
canvas, mesaBackend(query, 'es', 'wgpu'));
} else {
gl = new GlDevice(canvas);
}
// st/mesa and the gex_wgpu Gallium driver below the decoder: the only
// browser GL backend (there is no WebGL path).
const gl = await (await import('./mesa-gl.js')).MesaGlDevice.create(
canvas, mesaBackend(query, 'es', 'wgpu'));
if (!gl.ok) {
ctx.term.writeln(ctx.ansi.red(gl.error || 'This browser has no usable GPU API.'));
ctx.status('no GPU', 'bad');
@@ -569,7 +562,7 @@ const gears = {
};
ctx.actions([{ label: 'Run', primary: true, onClick: () => start(200) }]);
ctx.hint('every GL call crosses to this page and runs on WebGL');
ctx.hint('every GL call crosses to this page and runs on Mesa over WebGPU');
// `?frames=N` starts it straight away, so a link can carry the demo.
const asked = Number(new URLSearchParams(location.search).get('frames'));
@@ -1239,7 +1232,7 @@ const supertuxkart = {
client: stkLaunch,
intro: [
'SuperTuxKart is running as an AArch64 X11 application inside this page:',
'Xwayland -> gex-wayland; OpenGL -> gex-gl -> Mesa in this page -> WebGL or WebGPU.',
'Xwayland -> gex-wayland; OpenGL -> gex-gl -> Mesa in this page -> WebGPU.',
],
hint: 'SuperTuxKart and Xwayland rendering straight to WebGPU',
actions: (start) => {
-96
View File
@@ -1,96 +0,0 @@
// OpenGL 4.4 uncompressed pixel-transfer span calculation. This mirrors
// guest/gl/gexgl-pixel.h; both sides must agree before either touches memory.
export function defaultPixelStore() {
return { alignment: 4, rowLength: 0, imageHeight: 0,
skipPixels: 0, skipRows: 0, skipImages: 0 };
}
const mul = (a, b) => {
const value = a * b;
return Number.isSafeInteger(value) ? value : null;
};
const add = (a, b) => {
const value = a + b;
return Number.isSafeInteger(value) ? value : null;
};
function components(format) {
switch (format) {
case 0x1900: case 0x1901: case 0x1902: case 0x1903:
case 0x1904: case 0x1905: case 0x1906: case 0x1909:
case 0x8d94: case 0x8d95: case 0x8d96: case 0x84f9: return 1;
case 0x190a: case 0x8227: case 0x8228: return 2;
case 0x1907: case 0x80e0: case 0x8d98: case 0x8d9a: return 3;
case 0x1908: case 0x80e1: case 0x8d99: case 0x8d9b: return 4;
default: return 0;
}
}
function groupBytes(format, type) {
switch (type) {
case 0x8032: case 0x8362: return 1;
case 0x8033: case 0x8034: case 0x8363: case 0x8364:
case 0x8365: case 0x8366: return 2;
case 0x8035: case 0x8036: case 0x8367: case 0x8368:
case 0x84fa: case 0x8c3b: case 0x8c3e: return 4;
case 0x8dad: return 8;
}
const scalar = type === 0x1400 || type === 0x1401 ? 1
: type === 0x1402 || type === 0x1403 || type === 0x140b ? 2
: type === 0x1404 || type === 0x1405 || type === 0x1406 || type === 0x140c ? 4
: type === 0x140a ? 8 : 0;
return scalar * components(format);
}
// depth === null means 2D; a non-negative number means 3D.
export function pixelLayout(format, type, width, height, depth, store) {
const is3d = depth !== null;
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 0 || height < 0 ||
(is3d && (!Number.isInteger(depth) || depth < 0))) return null;
if (!width || !height || (is3d && !depth))
return { offset: 0, rowStride: 0, imageStride: 0, end: 0 };
if (type === 0x1a00) return null; // GL_BITMAP needs bit-level transport.
const group = groupBytes(format, type);
if (!group) return null;
const rowBytes = mul(store.rowLength || width, group);
if (rowBytes === null) return null;
const rowStride = Math.ceil(rowBytes / store.alignment) * store.alignment;
if (!Number.isSafeInteger(rowStride)) return null;
const imageStride = mul(rowStride, is3d && store.imageHeight ? store.imageHeight : height);
const oi = mul(is3d ? store.skipImages : 0, imageStride);
const or = mul(store.skipRows, rowStride);
const op = mul(store.skipPixels, group);
if ([imageStride, oi, or, op].some((value) => value === null)) return null;
let offset = add(oi, or); offset = offset === null ? null : add(offset, op);
const li = mul((is3d ? depth : 1) - 1, imageStride);
const lr = mul(height - 1, rowStride);
const active = mul(width, group);
if ([offset, li, lr, active].some((value) => value === null)) return null;
let end = add(offset, li); end = end === null ? null : add(end, lr);
end = end === null ? null : add(end, active);
return end === null ? null : { offset, rowStride, imageStride, end };
}
export function setPixelStore(pack, unpack, pname, param) {
if (!Number.isInteger(param) || param < 0) return false;
let state, field;
switch (pname) {
case 0x0d05: state = pack; field = 'alignment'; break;
case 0x0d02: state = pack; field = 'rowLength'; break;
case 0x806c: state = pack; field = 'imageHeight'; break;
case 0x0d04: state = pack; field = 'skipPixels'; break;
case 0x0d03: state = pack; field = 'skipRows'; break;
case 0x806b: state = pack; field = 'skipImages'; break;
case 0x0cf5: state = unpack; field = 'alignment'; break;
case 0x0cf2: state = unpack; field = 'rowLength'; break;
case 0x806e: state = unpack; field = 'imageHeight'; break;
case 0x0cf4: state = unpack; field = 'skipPixels'; break;
case 0x0cf3: state = unpack; field = 'skipRows'; break;
case 0x806d: state = unpack; field = 'skipImages'; break;
default: return false;
}
if (field === 'alignment' && ![1, 2, 4, 8].includes(param)) return false;
state[field] = param;
return true;
}
-523
View File
@@ -1,523 +0,0 @@
// The browser's half of the graphics device: the guest marshals each GL call
// into a buffer in its own memory, the shim hands this the address, and every
// command becomes a real WebGL call on a real canvas.
//
// WebGL 2 *is* OpenGL ES 3.0, so almost nothing here is a translation. The two
// exceptions are where the web refuses a raw pointer: object names (a table per
// kind, indexed by the guest's integer) and buffer data (a subarray view into
// guest memory, not a copy). The command encoding is generated from the Rust
// definition in gl-proto.js, so guest, native host and this cannot drift apart.
import { op, MAGIC, PROTOCOL_VERSION, HEADER_BYTES, error } from './gl-proto.js';
import { defaultPixelStore, pixelLayout, setPixelStore } from './gl-pixels.js';
import { captureIfRequested } from './gl-capture.js';
// Header word offsets, in 32-bit units.
const H_MAGIC = 0;
const H_PROTOCOL_VERSION = 7;
const H_CAPACITY = 1;
const H_TAIL = 2;
const H_REPLY_CAP = 3;
const H_REPLY_LEN = 4;
const H_STATUS = 5;
const H_FAILED_AT = 6;
/// GL constants this file needs by name. The guest sends numbers, so these
/// are only for the few places a decision is made.
const GL_VENDOR = 0x1f00;
const GL_RENDERER = 0x1f01;
const GL_VERSION = 0x1f02;
const GL_SHADING_LANGUAGE_VERSION = 0x8b8c;
const GL_COMPILE_STATUS = 0x8b81;
const GL_LINK_STATUS = 0x8b82;
/// Reads a command's arguments.
class Args {
constructor(view, start, end) {
this.view = view;
this.at = start;
this.end = end;
}
u32() {
if (this.at + 4 > this.end) return 0;
const v = this.view.getUint32(this.at, true);
this.at += 4;
return v;
}
i32() {
return this.u32() | 0;
}
f32() {
if (this.at + 4 > this.end) return 0;
const v = this.view.getFloat32(this.at, true);
this.at += 4;
return v;
}
u64() {
const low = this.u32(), high = this.u32();
const value = low + high * 0x100000000;
return Number.isSafeInteger(value) ? value : 0;
}
/// A length word then that many bytes, as a view into guest memory rather
/// than a copy: uploading a texture should not allocate.
blob(bytes) {
const len = this.u32();
const start = this.at;
if (start + len > this.end) return new Uint8Array(0);
this.at += len;
return bytes.subarray(start, start + len);
}
string(bytes) {
// `.slice()` copies to a non-shared buffer first: the guest's memory is a
// `SharedArrayBuffer` when threads are on (and always so through the gl
// bridge), and `TextDecoder.decode` refuses a view onto shared memory.
// `blob` itself stays a view -- WebGL's upload calls accept shared ones.
return new TextDecoder().decode(this.blob(bytes).slice());
}
}
/// Builds the reply the guest reads back.
class Reply {
constructor() {
this.parts = [];
this.length = 0;
}
u32(v) {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, v >>> 0, true);
this.parts.push(b);
this.length += 4;
}
blob(bytes) {
this.u32(bytes.length);
this.parts.push(bytes);
this.length += bytes.length;
const pad = (4 - (bytes.length % 4)) % 4;
if (pad) {
this.parts.push(new Uint8Array(pad));
this.length += pad;
}
}
string(s) {
this.blob(new TextEncoder().encode(s));
}
into(dst) {
let at = 0;
for (const p of this.parts) {
if (at + p.length > dst.length) break;
dst.set(p, at);
at += p.length;
}
return at;
}
}
export class GlDevice {
/// `canvas` is where the guest's default framebuffer goes. A guest drawing
/// to what it thinks is an offscreen pbuffer is drawing here, which is what
/// makes the picture appear without anything having to copy it.
constructor(canvas) {
this.canvas = canvas;
this.gl = canvas.getContext('webgl2', {
alpha: false,
antialias: false,
depth: true,
// The guest may read pixels back long after drawing them, and a page
// that discards the buffer after compositing would hand back nothing.
preserveDrawingBuffer: true,
});
this.buffers = new Map();
this.textures = new Map();
this.shaders = new Map();
this.programs = new Map();
this.framebuffers = new Map();
this.packStore = defaultPixelStore();
this.unpackStore = defaultPixelStore();
this.ok = !!this.gl;
this.capture = captureIfRequested(PROTOCOL_VERSION, canvas.width, canvas.height);
}
describe() {
if (!this.ok) return 'no WebGL 2';
const gl = this.gl;
return `${gl.getParameter(gl.VERSION)} / ${gl.getParameter(gl.RENDERER)}`;
}
/// Run one batch. `memory` is the guest's WebAssembly.Memory and `ring` the
/// address of the header inside it.
///
/// Everything is read through fresh views because the guest's memory can
/// grow between calls, which detaches any buffer held from before.
submit(memory, ring) {
const bytes = new Uint8Array(memory.buffer);
const words = new Uint32Array(memory.buffer, ring, HEADER_BYTES / 4);
if (words[H_MAGIC] !== MAGIC) return -1;
if (words[H_PROTOCOL_VERSION] !== PROTOCOL_VERSION) return -1;
const capacity = words[H_CAPACITY];
const tail = words[H_TAIL];
if (tail > capacity) return -1;
// Before execution: a batch that takes the context down with it is still
// the batch a replay needs.
this.capture?.record(
bytes.subarray(ring + HEADER_BYTES, ring + HEADER_BYTES + tail),
words[H_REPLY_CAP],
);
const view = new DataView(memory.buffer);
const base = ring + HEADER_BYTES;
const reply = new Reply();
let status = error.NONE;
let index = 0;
let at = 0;
while (at < tail) {
if (at + 8 > tail) {
status = error.TRUNCATED;
break;
}
const opcode = view.getUint32(base + at, true);
const len = view.getUint32(base + at + 4, true);
if (len < 8 || at + len > tail) {
status = error.TRUNCATED;
break;
}
const args = new Args(view, base + at + 8, base + at + len);
if (!this.one(opcode, args, bytes, reply)) {
status = error.BAD_OPCODE;
break;
}
at += len;
index++;
}
// Fresh views again: a command may have grown the guest's memory.
const out = new Uint32Array(memory.buffer, ring, HEADER_BYTES / 4);
const replyArea = new Uint8Array(
memory.buffer,
ring + HEADER_BYTES + capacity,
out[H_REPLY_CAP],
);
const written = reply.into(replyArea);
out[H_TAIL] = 0;
out[H_REPLY_LEN] = written;
out[H_STATUS] = status;
out[H_FAILED_AT] = index;
return 0;
}
/// Execute one command. `false` means the opcode is unknown, after which
/// the stream cannot be read any further.
one(opcode, a, bytes, reply) {
const gl = this.gl;
switch (opcode) {
case op.CONTEXT_CREATE:
// The client API and version the guest asked for. This device is
// WebGL 2 whatever the request, so they are read and not acted on --
// but they must be consumed, because a command whose payload is left
// unread is rejected as malformed.
a.u32();
a.u32();
a.u32();
reply.string(gl.getParameter(GL_VENDOR) ?? '');
reply.string(gl.getParameter(GL_RENDERER) ?? '');
reply.string(gl.getParameter(GL_VERSION) ?? '');
break;
case op.SURFACE_RESIZE: {
const w = a.i32(), h = a.i32();
// The guest asking for a pbuffer of some size is the closest thing
// this design has to a request for a window, so the canvas takes it.
if (w > 0 && h > 0) {
this.canvas.width = w;
this.canvas.height = h;
}
break;
}
case op.FINISH:
gl.finish();
reply.u32(0);
break;
case op.PRESENT:
// WebGL presents its default framebuffer implicitly. Keep the opcode
// so explicit backends can use the same guest driver and wire stream.
gl.flush();
// The count of frames shown, which the guest paces against. Here that
// is simply the count of presents processed -- the browser composites
// this canvas on its own schedule and nothing defers it to a later
// display() the way the WebGPU backend does -- so the guest is never
// ahead and never throttles itself on this path.
this.presents = (this.presents ?? 0) + 1;
reply.u32(this.presents);
break;
case op.CLEAR_COLOR: gl.clearColor(a.f32(), a.f32(), a.f32(), a.f32()); break;
case op.CLEAR_DEPTH: gl.clearDepth(a.f32()); break;
case op.CLEAR: gl.clear(a.u32()); break;
case op.VIEWPORT: gl.viewport(a.i32(), a.i32(), a.i32(), a.i32()); break;
case op.SCISSOR: gl.scissor(a.i32(), a.i32(), a.i32(), a.i32()); break;
case op.ENABLE: gl.enable(a.u32()); break;
case op.DISABLE: gl.disable(a.u32()); break;
case op.BLEND_FUNC: gl.blendFunc(a.u32(), a.u32()); break;
case op.DEPTH_FUNC: gl.depthFunc(a.u32()); break;
case op.CULL_FACE: gl.cullFace(a.u32()); break;
case op.FRONT_FACE: gl.frontFace(a.u32()); break;
case op.PIXEL_STOREI: {
const pname = a.u32(), param = a.i32();
gl.pixelStorei(pname, param);
setPixelStore(this.packStore, this.unpackStore, pname, param);
break;
}
case op.GEN_BUFFERS: {
const n = a.u32();
for (let i = 0; i < n; i++) this.buffers.set(a.u32(), gl.createBuffer());
break;
}
case op.DELETE_BUFFERS: {
const n = a.u32();
for (let i = 0; i < n; i++) {
const name = a.u32();
gl.deleteBuffer(this.buffers.get(name) ?? null);
this.buffers.delete(name);
}
break;
}
case op.BIND_BUFFER: {
const target = a.u32(), name = a.u32();
gl.bindBuffer(target, name ? (this.buffers.get(name) ?? null) : null);
break;
}
case op.BUFFER_DATA: {
const target = a.u32(), usage = a.u32(), size = a.u64(), hasData = a.u32() !== 0;
const data = a.blob(bytes);
if (!Number.isSafeInteger(size) || size < 0 ||
(hasData ? data.length !== size : data.length !== 0)) { a.ok = false; break; }
gl.bufferData(target, hasData ? data : size, usage);
break;
}
case op.BUFFER_SUB_DATA: {
const target = a.u32(), offset = a.u64(), size = a.u64(), data = a.blob(bytes);
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(size) ||
offset < 0 || size < 0 || data.length !== size) { a.ok = false; break; }
gl.bufferSubData(target, offset, data);
break;
}
case op.CREATE_SHADER: {
const name = a.u32(), type = a.u32();
this.shaders.set(name, gl.createShader(type));
break;
}
case op.SHADER_SOURCE: {
const name = a.u32(), src = a.string(bytes);
const s = this.shaders.get(name);
if (s) gl.shaderSource(s, src);
break;
}
case op.COMPILE_SHADER: {
const s = this.shaders.get(a.u32());
if (s) gl.compileShader(s);
break;
}
case op.DELETE_SHADER: {
const name = a.u32();
gl.deleteShader(this.shaders.get(name) ?? null);
this.shaders.delete(name);
break;
}
case op.CREATE_PROGRAM:
this.programs.set(a.u32(), gl.createProgram());
break;
case op.ATTACH_SHADER: {
const p = this.programs.get(a.u32()), s = this.shaders.get(a.u32());
if (p && s) gl.attachShader(p, s);
break;
}
case op.BIND_ATTRIB_LOCATION: {
const p = this.programs.get(a.u32());
const index = a.u32(), name = a.string(bytes);
if (p) gl.bindAttribLocation(p, index, name);
break;
}
case op.LINK_PROGRAM: {
const p = this.programs.get(a.u32());
if (p) gl.linkProgram(p);
break;
}
case op.USE_PROGRAM: {
const name = a.u32();
// Remembered because a uniform is set against whatever program is
// current, and the integer the guest holds only means something
// relative to that program's list.
this.current = name ? (this.programs.get(name) ?? null) : null;
gl.useProgram(this.current);
break;
}
case op.DELETE_PROGRAM: {
const name = a.u32();
gl.deleteProgram(this.programs.get(name) ?? null);
this.programs.delete(name);
break;
}
case op.GET_UNIFORM_LOCATION: {
const p = this.programs.get(a.u32()), name = a.string(bytes);
// WebGL hands back an opaque location object, and the guest wants an
// integer. They are kept in a list per program and the index stands
// in for the location, which is all the guest ever does with it.
if (!p) { reply.u32(0xffffffff); break; }
if (!p._locations) p._locations = [];
const loc = gl.getUniformLocation(p, name);
if (loc === null) { reply.u32(0xffffffff); break; }
p._locations.push(loc);
reply.u32(p._locations.length - 1);
break;
}
case op.GET_ATTRIB_LOCATION: {
const p = this.programs.get(a.u32()), name = a.string(bytes);
reply.u32(p ? gl.getAttribLocation(p, name) : -1);
break;
}
case op.GET_SHADER_IV: {
const s = this.shaders.get(a.u32()), pname = a.u32();
const v = s ? gl.getShaderParameter(s, pname) : 0;
reply.u32(pname === GL_COMPILE_STATUS ? (v ? 1 : 0) : (v | 0));
break;
}
case op.GET_PROGRAM_IV: {
const p = this.programs.get(a.u32()), pname = a.u32();
const v = p ? gl.getProgramParameter(p, pname) : 0;
reply.u32(pname === GL_LINK_STATUS ? (v ? 1 : 0) : (v | 0));
break;
}
case op.GET_SHADER_INFO_LOG: {
const s = this.shaders.get(a.u32());
reply.string(s ? (gl.getShaderInfoLog(s) ?? '') : '');
break;
}
case op.GET_PROGRAM_INFO_LOG: {
const p = this.programs.get(a.u32());
reply.string(p ? (gl.getProgramInfoLog(p) ?? '') : '');
break;
}
case op.UNIFORM_1I: gl.uniform1i(this.loc(a.i32()), a.i32()); break;
case op.UNIFORM_1F: gl.uniform1f(this.loc(a.i32()), a.f32()); break;
case op.UNIFORM_2F: gl.uniform2f(this.loc(a.i32()), a.f32(), a.f32()); break;
case op.UNIFORM_3F:
gl.uniform3f(this.loc(a.i32()), a.f32(), a.f32(), a.f32());
break;
case op.UNIFORM_4F:
gl.uniform4f(this.loc(a.i32()), a.f32(), a.f32(), a.f32(), a.f32());
break;
case op.UNIFORM_MATRIX4FV: {
const loc = this.loc(a.i32());
const count = a.u32(), transpose = a.u32() !== 0;
const data = a.blob(bytes);
// A view rather than a copy, but it has to be aligned for Float32Array
// and the guest's blob is only guaranteed word-aligned.
const floats = new Float32Array(count * 16);
new Uint8Array(floats.buffer).set(data.subarray(0, count * 64));
gl.uniformMatrix4fv(loc, transpose, floats);
break;
}
case op.ENABLE_VERTEX_ATTRIB_ARRAY: gl.enableVertexAttribArray(a.u32()); break;
case op.DISABLE_VERTEX_ATTRIB_ARRAY: gl.disableVertexAttribArray(a.u32()); break;
case op.VERTEX_ATTRIB_POINTER: {
const index = a.u32(), size = a.i32(), type = a.u32();
const normalized = a.u32() !== 0, stride = a.i32(), offset = a.u32();
gl.vertexAttribPointer(index, size, type, normalized, stride, offset);
break;
}
case op.GEN_TEXTURES: {
const n = a.u32();
for (let i = 0; i < n; i++) this.textures.set(a.u32(), gl.createTexture());
break;
}
case op.DELETE_TEXTURES: {
const n = a.u32();
for (let i = 0; i < n; i++) {
const name = a.u32();
gl.deleteTexture(this.textures.get(name) ?? null);
this.textures.delete(name);
}
break;
}
case op.BIND_TEXTURE: {
const target = a.u32(), name = a.u32();
gl.bindTexture(target, name ? (this.textures.get(name) ?? null) : null);
break;
}
case op.ACTIVE_TEXTURE: gl.activeTexture(a.u32()); break;
case op.TEX_PARAMETER_I: gl.texParameteri(a.u32(), a.u32(), a.i32()); break;
case op.TEX_IMAGE_2D: {
const target = a.u32(), level = a.i32(), internal = a.i32();
const w = a.i32(), h = a.i32(), border = a.i32();
const format = a.u32(), type = a.u32(), pbo = a.u32() !== 0;
const pointer = a.u64(), data = a.blob(bytes);
gl.texImage2D(target, level, internal, w, h, border, format, type,
pbo ? pointer : (pointer ? data : null));
break;
}
case op.TEX_SUB_IMAGE_2D: {
const target = a.u32(), level = a.i32();
const x = a.i32(), y = a.i32(), w = a.i32(), h = a.i32();
const format = a.u32(), type = a.u32(), pbo = a.u32() !== 0;
const pointer = a.u64(), data = a.blob(bytes);
gl.texSubImage2D(target, level, x, y, w, h, format, type,
pbo ? pointer : data);
break;
}
case op.DRAW_ARRAYS: gl.drawArrays(a.u32(), a.i32(), a.i32()); break;
case op.DRAW_ELEMENTS: gl.drawElements(a.u32(), a.i32(), a.u32(), a.u32()); break;
case op.READ_PIXELS: {
const x = a.i32(), y = a.i32(), w = a.i32(), h = a.i32();
const format = a.u32(), type = a.u32(), pbo = a.u32() !== 0;
const pointer = a.u64();
if (pbo) {
gl.readPixels(x, y, w, h, format, type, pointer);
break;
}
const layout = pixelLayout(format, type, w, h, null, this.packStore);
const px = new Uint8Array(layout?.end ?? 0);
gl.readPixels(x, y, w, h, format, type, px);
reply.u32(layout?.offset ?? 0);
reply.blob(px.subarray(layout?.offset ?? 0));
break;
}
case op.GET_STRING: {
const name = a.u32();
let s = gl.getParameter(name);
if (name === GL_SHADING_LANGUAGE_VERSION && !s) s = '';
reply.string(typeof s === 'string' ? s : String(s ?? ''));
break;
}
case op.GET_INTEGERV: {
const v = gl.getParameter(a.u32());
const list = typeof v === 'number' ? [v] : Array.from(v ?? [0]);
for (let i = 0; i < 4; i++) reply.u32(list[i] ?? 0);
break;
}
case op.GET_ERROR:
reply.u32(gl.getError());
break;
default:
return false;
}
return true;
}
/// Turn the integer the guest was given back into a WebGL uniform
/// location, against whichever program is current -- which is the program
/// the guest was looking at when it asked.
loc(index) {
const p = this.current;
if (!p || !p._locations || index < 0) return null;
return p._locations[index] ?? null;
}
}
+1 -1
View File
@@ -463,7 +463,7 @@ export async function runProgram(
// that never asked, and the guest's only symptom is an empty GL_VERSION
// much later.
console.warn(`[gex] runtime rings: gl=${!!gl} wl=${!!wl}`);
// The GPU: a `GlDevice` (web/src/gl.js) the guest drives through
// The GPU: a `MesaGlDevice` (web/src/mesa-gl.js) the guest drives through
// libGLESv2/libEGL marshallers over a command ring. It needs a
// SharedArrayBuffer bridge -- `gl_submit` is synchronous inside an
// ioctl and the canvas is here, not in the Worker. Without this the `gl`