gex-gl: client-memory arrays as CLIENT_ARRAY, not scratch buffers rewritten per draw

The NT layer copied each of Noita's client-side vertex arrays into a
scratch buffer of its own with glBufferData before every draw: 142 a
frame. The batched gex_wgpu driver cannot rewrite a buffer the pending
batch reads, so each became a staged copy that ended the render pass:
51 passes, 123 copyBufferToBuffer, 77 views and 15 createBuffer a frame
for 47 draws, against pewasm's 15 passes and no copies (pewasm hands
Mesa the pointers).

op::CLIENT_ARRAY (200) carries the bytes with their pointer command; the
host keeps one copy per array slot and runs the pointer command with the
bytes' address and no GL_ARRAY_BUFFER bound, so st/mesa uploads them
through its stream uploader like any user array. Both hosts (the
browser's Mesa decoder and gex-gl's native one) implement it. The
replayed Noita frame (edger, 600 frames, two passes): 8.17 and 8.45 ms
of CPU before, 5.32 and 5.33 after; 15 passes, 16 views, 83 writeBuffer,
no copies, no buffers made: pewasm's counts. GEX_NT_GL_CLIENT_ARRAYS=
buffer keeps the old path for A/B runs.

Also in the decoder: the periodic draw-target report sat outside its
if for want of braces, so ten formatted stderr lines (and the page's
console plumbing for each) ran every displayed frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_d2453757b8a84fdbb4ee7c9e093141df
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-10-07 16:32:24 -04:00
1 parent 21baa051c1
commit bdb682d9f4
8 files changed
+210 -22

No files matched your search

+91
View File
@@ -462,6 +462,9 @@ pub struct Gl {
frame: Option<Frame>,
/// PRESENTs so far.
pub presents: u64,
/// `op::CLIENT_ARRAY`'s bytes by slot: GL keeps the pointer, so they
/// stay until the slot's next one.
client_arrays: std::collections::HashMap<u32, Vec<u8>>,
}
/// A presented frame: RGBA rows, top first.
@@ -600,6 +603,7 @@ impl Gl {
keep_frames: false,
frame: None,
presents: 0,
client_arrays: std::collections::HashMap::new(),
})
}
}
@@ -1033,6 +1037,32 @@ impl Gl {
let names: Vec<GLuint> = (0..n).filter_map(|_| a.u32()).collect();
glDeleteTextures(names.len() as GLsizei, names.as_ptr());
}
op::CLIENT_ARRAY => {
// { slot, op, n, n words, bytes }: run the pointer
// command `op` with the bytes' address as its pointer and
// no GL_ARRAY_BUFFER bound (proto.rs).
let (Some(slot), Some(inner), Some(n)) = (a.u32(), a.u32(), a.u32()) else {
return true;
};
if n > 8 || inner == op::CLIENT_ARRAY {
return false;
}
let mut body = Vec::with_capacity(n as usize * 4 + 8);
for _ in 0..n {
let Some(w) = a.u32() else { return true };
body.extend_from_slice(&w.to_le_bytes());
}
let Some(bytes) = a.blob() else { return true };
let mem = self.client_arrays.entry(slot).or_default();
mem.clear();
mem.extend_from_slice(bytes);
if mem.is_empty() {
mem.push(0);
}
body.extend_from_slice(&(mem.as_ptr() as usize as u64).to_le_bytes());
glBindBuffer(0x8892, 0); // GL_ARRAY_BUFFER
return self.one(inner, &body, reply);
}
op::BIND_BUFFER => {
if let (Some(t), Some(n)) = (a.u32(), a.u32()) {
glBindBuffer(t, n);
@@ -2185,6 +2215,67 @@ mod tests {
assert_eq!(pixel16(&px, 14, 8), (0, 255, 0, 255), "the immediate quad, moved by the loaded matrix");
}
/// CLIENT_ARRAY: client-memory arrays carried with their pointer
/// commands, drawn while the guest's own buffer stays bound.
#[test]
fn client_array_draws_from_the_bytes_it_carries() {
let mut gl = match Gl::new(16, 16) {
Ok(g) => g,
Err(e) => {
eprintln!("skipping: no host GL ({e})");
return;
}
};
const GL_VERTEX_ARRAY: u32 = 0x8074;
const GL_COLOR_ARRAY: u32 = 0x8076;
const GL_QUADS: u32 = 0x0007;
let mut b = Batch::new();
b.cmd(op::CONTEXT_CREATE, &[client_api::DESKTOP, 2, 1]);
let _ = gl.run(b.bytes());
if !gl.is_desktop() {
eprintln!("skipping: the host has no desktop OpenGL");
return;
}
let f = |v: f32| v.to_bits();
// The left half in clip space, red; then the right half, blue, from
// the same slots: each CLIENT_ARRAY replaces its slot's bytes.
let quad = |x0: f32, x1: f32| {
let mut v = Vec::new();
for (x, y) in [(x0, -1.0f32), (x1, -1.0), (x1, 1.0), (x0, 1.0)] {
v.extend_from_slice(&x.to_le_bytes());
v.extend_from_slice(&y.to_le_bytes());
}
v
};
let colour = |c: [u8; 4]| c.repeat(4);
let mut b = Batch::new();
b.cmd(op::CLEAR_COLOR, &[0, 0, 0, f(1.0)]);
b.cmd(op::CLEAR, &[GL_COLOR_BUFFER_BIT]);
b.cmd(op::GEN_BUFFERS, &[1, 9]);
b.cmd(op::BIND_BUFFER, &[GL_ARRAY_BUFFER, 9]);
b.cmd(op::ENABLE_CLIENT_STATE, &[GL_VERTEX_ARRAY]);
b.cmd(op::ENABLE_CLIENT_STATE, &[GL_COLOR_ARRAY]);
for (x0, x1, c) in [(-1.0, 0.0, [255, 0, 0, 255]), (0.0, 1.0, [0, 0, 255, 255])] {
let v = quad(x0, x1);
b.raw(op::CLIENT_ARRAY, &[0, op::VERTEX_POINTER, 3, 2, GL_FLOAT, 8, v.len() as u32], &v);
let c = colour(c);
b.raw(op::CLIENT_ARRAY, &[1, op::COLOR_POINTER, 3, 4, GL_UNSIGNED_BYTE, 4, c.len() as u32], &c);
b.cmd(op::BIND_BUFFER, &[GL_ARRAY_BUFFER, 9]);
b.cmd(op::DRAW_ARRAYS, &[GL_QUADS, 0, 4]);
}
read_back(&mut b);
b.cmd(op::GET_ERROR, &[]);
let (reply, status, _) = gl.run(b.bytes());
assert_eq!(status, error::NONE, "every command was understood");
let r = reply.bytes();
let mut at = 0;
let _offset = read_u32(r, &mut at);
let px = read_blob(r, &mut at);
assert_eq!(read_u32(r, &mut at), 0, "GL reported no error");
assert_eq!(pixel16(&px, 3, 8), (255, 0, 0, 255), "the first quad, from its own bytes");
assert_eq!(pixel16(&px, 12, 8), (0, 0, 255, 255), "the second, from the slots' new bytes");
}
/// Protocol 12's attribute stack, and the host's uniform vectors and
/// separate blend functions (opcodes that already existed).
#[test]
+9
View File
@@ -422,6 +422,15 @@ pub mod op {
pub const PUSH_ATTRIB: u32 = 198;
/// `{}` -- glPopAttrib.
pub const POP_ATTRIB: u32 = 199;
/// A client-memory vertex array: `{ slot, op, n, n words, bytes }`.
/// The host keeps the bytes (one copy per `slot`, until that slot's next
/// CLIENT_ARRAY) and runs pointer command `op` with the `n` argument
/// words and the bytes' address as its pointer, with no
/// GL_ARRAY_BUFFER bound. How the NT layer passes glVertexPointer-style
/// client arrays: GL then uploads them the way it uploads any user
/// array, instead of the guest rewriting a buffer of its own before each
/// draw (which a batched backend has to stage and copy).
pub const CLIENT_ARRAY: u32 = 200;
}
/// Writes commands into a batch. For the host's tests: the guest half is C,
+65 -21
View File
@@ -1039,35 +1039,65 @@ fn pointer_op(name: &str) -> u32 {
generated::POINTER_PLANS.binary_search_by(|e| e.0.cmp(name)).map(|i| generated::POINTER_PLANS[i].1).unwrap_or(0)
}
/// Send fixed array `i`'s pointer, as `offset` into the bound array buffer.
fn send_array_pointer(c: &mut Call<'_>, i: usize, a: &Array, offset: u64) -> R<()> {
let [lo, hi] = proto::Batch::words64(offset);
/// Fixed array `i`'s pointer command and its words before the pointer. A
/// texture coordinate array's also needs its unit made the client active
/// texture first ([`send_array_pointer`] does).
fn array_pointer(i: usize, a: &Array) -> (u32, Vec<u32>) {
let (size, ty, stride) = (a.size as u32, a.ty, a.stride as u32);
match i {
VERTEX => cmd(c, op::VERTEX_POINTER, &[size, ty, stride, lo, hi], &[]),
COLOR => cmd(c, op::COLOR_POINTER, &[size, ty, stride, lo, hi], &[]),
NORMAL => cmd(c, pointer_op("glNormalPointer"), &[ty, stride, lo, hi], &[]),
SECONDARY => cmd(c, pointer_op("glSecondaryColorPointer"), &[size, ty, stride, lo, hi], &[]),
FOG => cmd(c, pointer_op("glFogCoordPointer"), &[ty, stride, lo, hi], &[]),
_ => {
cmd(c, op::CLIENT_ACTIVE_TEXTURE, &[0x84c0 + (i - TEXCOORD) as u32], &[])?;
cmd(c, op::TEX_COORD_POINTER, &[size, ty, stride, lo, hi], &[])
}
VERTEX => (op::VERTEX_POINTER, vec![size, ty, stride]),
COLOR => (op::COLOR_POINTER, vec![size, ty, stride]),
NORMAL => (pointer_op("glNormalPointer"), vec![ty, stride]),
SECONDARY => (pointer_op("glSecondaryColorPointer"), vec![size, ty, stride]),
FOG => (pointer_op("glFogCoordPointer"), vec![ty, stride]),
_ => (op::TEX_COORD_POINTER, vec![size, ty, stride]),
}
}
/// Generic attribute `i`'s pointer command and its words before the pointer.
fn attrib_pointer(i: usize, a: &Attrib) -> (u32, Vec<u32>) {
let (idx, size, ty, stride) = (i as u32, a.size as u32, a.ty, a.stride as u32);
match a.kind {
0 => (pointer_op("glVertexAttribPointer"), vec![idx, size, ty, a.normalized as u32, stride]),
1 => (pointer_op("glVertexAttribIPointer"), vec![idx, size, ty, stride]),
_ => (pointer_op("glVertexAttribLPointer"), vec![idx, size, ty, stride]),
}
}
/// Send fixed array `i`'s pointer, as `offset` into the bound array buffer.
fn send_array_pointer(c: &mut Call<'_>, i: usize, a: &Array, offset: u64) -> R<()> {
let (opcode, mut words) = array_pointer(i, a);
if (TEXCOORD..ARRAYS).contains(&i) {
cmd(c, op::CLIENT_ACTIVE_TEXTURE, &[0x84c0 + (i - TEXCOORD) as u32], &[])?;
}
words.extend(proto::Batch::words64(offset));
cmd(c, opcode, &words, &[])
}
/// Send generic attribute `i`'s pointer, as `offset` into the bound array
/// buffer.
fn send_attrib_pointer(c: &mut Call<'_>, i: usize, a: &Attrib, offset: u64) -> R<()> {
let [lo, hi] = proto::Batch::words64(offset);
let (idx, size, ty, stride) = (i as u32, a.size as u32, a.ty, a.stride as u32);
match a.kind {
0 => cmd(c, pointer_op("glVertexAttribPointer"), &[idx, size, ty, a.normalized as u32, stride, lo, hi], &[]),
1 => cmd(c, pointer_op("glVertexAttribIPointer"), &[idx, size, ty, stride, lo, hi], &[]),
_ => cmd(c, pointer_op("glVertexAttribLPointer"), &[idx, size, ty, stride, lo, hi], &[]),
}
let (opcode, mut words) = attrib_pointer(i, a);
words.extend(proto::Batch::words64(offset));
cmd(c, opcode, &words, &[])
}
/// A client-memory array's bytes and its pointer command, as one
/// `op::CLIENT_ARRAY`: the host keeps the bytes in slot `slot` and points GL
/// at them. Replaces a scratch buffer of ours rewritten with glBufferData
/// before every draw (`upload_scratch`), which a batched GL backend can only
/// do as a staged copy that ends the render pass.
fn send_client_array(c: &mut Call<'_>, slot: u32, opcode: u32, words: &[u32], data: &[u8]) -> R<()> {
let mut head = Vec::with_capacity(3 + words.len());
head.extend_from_slice(&[slot, opcode, words.len() as u32]);
head.extend_from_slice(words);
cmd_blob(c, op::CLIENT_ARRAY, &head, data)
}
/// Whether client arrays go as `op::CLIENT_ARRAY` (default) or through
/// scratch buffers (`GEX_NT_GL_CLIENT_ARRAYS=buffer`, for A/B runs).
pub static CLIENT_ARRAY_OP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// Copy `bytes` of client memory at `pointer` into scratch buffer `*scratch`
/// (made on first use), left bound to GL_ARRAY_BUFFER.
fn upload_scratch(c: &mut Call<'_>, scratch: u32, target: u32, data: &[u8]) -> R<u32> {
@@ -1093,6 +1123,7 @@ fn upload_arrays_instanced(c: &mut Call<'_>, vertices: usize, instances: usize)
(s.arrays, s.attribs, s.array_buffer)
};
const GL_ARRAY_BUFFER: u32 = 0x8892;
let by_op = CLIENT_ARRAY_OP.load(std::sync::atomic::Ordering::Relaxed);
let mut rebound = false;
for (i, a) in arrays.iter().enumerate() {
if !a.enabled || a.buffer != 0 || a.pointer == 0 || vertices == 0 {
@@ -1104,9 +1135,17 @@ fn upload_arrays_instanced(c: &mut Call<'_>, vertices: usize, instances: usize)
}
let stride = if a.stride > 0 { a.stride as usize } else { elem };
let data = c.g.bytes(a.pointer, (vertices - 1) * stride + elem)?;
rebound = true;
if by_op {
if (TEXCOORD..ARRAYS).contains(&i) {
cmd(c, op::CLIENT_ACTIVE_TEXTURE, &[0x84c0 + (i - TEXCOORD) as u32], &[])?;
}
let (opcode, words) = array_pointer(i, a);
send_client_array(c, i as u32, opcode, &words, &data)?;
continue;
}
let scratch = upload_scratch(c, a.scratch, GL_ARRAY_BUFFER, &data)?;
c.p.gl.lock().unwrap().arrays[i].scratch = scratch;
rebound = true;
send_array_pointer(c, i, a, 0)?;
}
for (i, a) in attribs.iter().enumerate() {
@@ -1120,9 +1159,14 @@ fn upload_arrays_instanced(c: &mut Call<'_>, vertices: usize, instances: usize)
let stride = if a.stride > 0 { a.stride as usize } else { elem };
let count = if a.divisor == 0 { vertices } else { instances.div_ceil(a.divisor as usize).max(1) };
let data = c.g.bytes(a.pointer, (count - 1) * stride + elem)?;
rebound = true;
if by_op {
let (opcode, words) = attrib_pointer(i, a);
send_client_array(c, 64 + i as u32, opcode, &words, &data)?;
continue;
}
let scratch = upload_scratch(c, a.scratch, GL_ARRAY_BUFFER, &data)?;
c.p.gl.lock().unwrap().attribs[i].scratch = scratch;
rebound = true;
send_attrib_pointer(c, i, a, 0)?;
}
if rebound {
+1
View File
@@ -198,6 +198,7 @@
#define GEXGL_ALPHA_FUNC 197
#define GEXGL_PUSH_ATTRIB 198
#define GEXGL_POP_ATTRIB 199
#define GEXGL_CLIENT_ARRAY 200 /* the NT layer's; see proto.rs */
#define GEXGL_SURFACE_WAYLAND 0
#define GEXGL_SURFACE_X 1
+1
View File
@@ -319,6 +319,7 @@ mod imp {
nt::opengl::STATS.store(flag("GEX_NT_GL_STATS"), Ordering::Relaxed);
nt::opengl::FAST.store(var("GEX_NT_GL_FAST").as_deref() != Some("0"), Ordering::Relaxed);
nt::opengl::FAST_CLIENT_STATE.store(var("GEX_NT_GL_FAST").as_deref() != Some("nocs"), Ordering::Relaxed);
nt::opengl::CLIENT_ARRAY_OP.store(var("GEX_NT_GL_CLIENT_ARRAYS").as_deref() != Some("buffer"), Ordering::Relaxed);
TRAP_PRINT.store(flag("GEX_NT_TRAP_PRINT"), Ordering::Relaxed);
crate::cgen::TRAP_CALLOUTS.store(flag("GEX_NT_CALLOUT_TRAP"), Ordering::Relaxed);
if let Some(k) = var("GEX_NT_TICK_EVERY").and_then(|v| v.parse().ok()) {
+1
View File
@@ -275,6 +275,7 @@ fn start_ticker(memory: gex_mem::GuestMemory, p: std::sync::Weak<nt::Process>, s
nt::opengl::STATS.store(gl_stats, std::sync::atomic::Ordering::Relaxed);
nt::opengl::FAST.store(std::env::var("GEX_NT_GL_FAST").map_or(true, |v| v != "0"), std::sync::atomic::Ordering::Relaxed);
nt::opengl::FAST_CLIENT_STATE.store(std::env::var("GEX_NT_GL_FAST").map_or(true, |v| v != "nocs"), std::sync::atomic::Ordering::Relaxed);
nt::opengl::CLIENT_ARRAY_OP.store(std::env::var("GEX_NT_GL_CLIENT_ARRAYS").map_or(true, |v| v != "buffer"), std::sync::atomic::Ordering::Relaxed);
let peek = std::env::var("GEX_NT_PEEK").ok().and_then(|v| {
let (addr, deref) = match v.strip_suffix('*') {
Some(a) => (a, true),
+41 -1
View File
@@ -172,6 +172,9 @@ enum Op : uint32_t {
ALPHA_FUNC = 197,
/* Protocol 12: the attribute stack. */
PUSH_ATTRIB = 198, POP_ATTRIB = 199,
/* A client-memory vertex array's bytes with its pointer command; see
* crates/gex-gl/src/proto.rs. */
CLIENT_ARRAY = 200,
};
struct Reader {
@@ -208,6 +211,10 @@ struct Reader {
};
std::vector<uint8_t> reply;
/* CLIENT_ARRAY's bytes, one copy per array slot. GL keeps the pointer, so the
* bytes stay until the slot's next CLIENT_ARRAY replaces them -- which the
* guest sends before every draw that reads a client array. */
std::unordered_map<uint32_t, std::vector<uint8_t>> client_arrays;
std::unordered_map<uint32_t, GLuint> buffers, textures, shaders, programs;
/* The head of each shader's source, kept only so a failed compile can show
* what it was handed. A GLSL error names a line and column and nothing else,
@@ -1341,7 +1348,10 @@ bool display_backend() {
* against how many were declined. A frame presents either way, so this
* is what distinguishes "drawing nothing" from "not started". */
static unsigned frames;
if ((frames++ % 600) == 0)
/* Braced: the report below was once outside the `if` by mistake, so
* its ten formatted stderr lines (and the page's console plumbing
* for each) ran on every displayed frame. */
if ((frames++ % 600) == 0) {
fprintf(stderr,
"gex-wgpu: frame %u -- issued %u, declined %u "
"(constant %u, texture %u, vertex %u; last: %s)\n",
@@ -1351,6 +1361,7 @@ bool display_backend() {
gex_wgpu_declined_last_why ? gex_wgpu_declined_last_why
: "-");
gex_wgpu_report_draw_targets();
}
}
GEX_PRESENT_MARK(copy_begin);
WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(device, nullptr);
@@ -2485,6 +2496,35 @@ bool one(uint32_t op, Reader& a) {
break;
}
case CLIENT_ACTIVE_TEXTURE: glClientActiveTexture(a.u32()); break;
/* { slot, op, n, n words, bytes }: a client-memory array, as pewasm's
* GL takes them -- a pointer into memory, which st/mesa uploads with its
* stream uploader. The guest used to copy each one into a buffer of its
* own with glBufferData before every draw, and a buffer the pending batch
* already reads cannot be rewritten in place: every such upload became a
* staged copy that split the render pass (Noita: 142 a frame, 52 passes
* for 47 draws). `op` is the pointer command (glVertexPointer's,
* glVertexAttribPointer's, ...) and the words are its arguments before
* the pointer, which becomes the bytes' address here; it runs with no
* GL_ARRAY_BUFFER bound, and the guest rebinds its own afterwards. */
case CLIENT_ARRAY: {
const uint32_t slot = a.u32(), inner = a.u32(), n = a.u32();
if (!a.ok || n > 8 || inner == CLIENT_ARRAY) return false;
uint32_t words[8];
for (uint32_t i = 0; i < n; ++i) words[i] = a.u32();
uint32_t len = 0;
const uint8_t* bytes = a.blob(len);
if (!a.ok) return false;
std::vector<uint8_t>& mem = client_arrays[slot];
mem.assign(bytes, bytes + len);
if (mem.empty()) mem.push_back(0);
uint8_t body[8 * 4 + 8];
std::memcpy(body, words, n * 4);
const uint64_t at = reinterpret_cast<uintptr_t>(mem.data());
std::memcpy(body + n * 4, &at, 8);
glBindBuffer(GL_ARRAY_BUFFER, 0);
Reader r{body, body + n * 4 + 8};
return one(inner, r);
}
case ORTHO: {
const GLdouble l = a.f64(), r = a.f64(), b = a.f64();
const GLdouble t = a.f64(), n = a.f64(), f = a.f64();
+1
View File
@@ -195,6 +195,7 @@ export const op = {
ALPHA_FUNC: 197,
PUSH_ATTRIB: 198,
POP_ATTRIB: 199,
CLIENT_ARRAY: 200,
};
export const MAGIC = 0x47584547;