You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
LEGACY_GL_EMULATION: planar client-side vertex arrays are not re-read on later glDrawArrays — stale vertex data is drawn #27945
emcc 6.0.12; all code references below verified present on main (src/lib/libglemu.js) as of today. Flags: -sLEGACY_GL_EMULATION -sGL_ENABLE_GET_PROC_ADDRESS (found porting a fixed-function GL 1.x game — the namco22-decompile static recompilation of Namco System 22 arcade titles — to WebAssembly).
Summary
When client-side vertex arrays are planar (each attribute in its own tightly packed array — case (1) in prepareClientAttributes), the attribute data is copied into GLImmediate.restrideBufferonly when GLImmediate.modifiedClientAttributes is set. Nothing sets that flag between draw calls: the only setters are setClientAttribute (the gl*Pointer entry points), glEnableClientState/glDisableClientState, glEnd, and VAO binding. Renderer.cleanup() does not touch it, so this is independent of GL_UNSAFE_OPTS.
Desktop GL semantics require client memory to be dereferenced at glDrawArrays time. An application that specifies its pointers once and rewrites the array contents between draws — ordinary GL 1.x usage for a batching renderer — gets the first draw's vertex data re-uploaded for every subsequent draw.
Compounding detail
The restride pass overwrites each attribute's pointer to point into the staging buffer:
attr.pointer=start+attr.offset;
so even if the app re-specifies one pointer (arming the flag), the re-copy reads the other attributes from the staging buffer onto themselves — they stay stale. Every pointer must be re-issued to fully recover (that is the workaround we ship).
Repro sketch
staticfloatxy[3*2], rgba[3*4];
glEnableClientState(GL_VERTEX_ARRAY); glVertexPointer(2, GL_FLOAT, 0, xy);
glEnableClientState(GL_COLOR_ARRAY); glColorPointer(4, GL_FLOAT, 0, rgba);
fill(xy, rgba, TRIANGLE_A); glDrawArrays(GL_TRIANGLES, 0, 3);
fill(xy, rgba, TRIANGLE_B); glDrawArrays(GL_TRIANGLES, 0, 3);
/* native GL: draws A then B. Emulation: draws A twice. */
Real-world impact
In our port this presented as whole-scene corruption in any depth-off render pass: every flush drew the first flush's geometry under later flushes' textures. (Depth-tested passes escaped only because a glVertexPointer swap per flush happened to re-arm the flag.)
Suggested fix
Keep the (expensive) layout computation cached, but perform the data copy for the planar path on every draw — it is already documented as the slow path, and correctness should come first. Storing the staging pointer in a separate field instead of clobbering attr.pointer would also make partial re-specification behave.
Agree that yeah, the vertex data should be read when the draw occurs, not when the pointers are bound.
Reading the code with that in mind, it does seem like the state caching boolean logic GLImmediate.modifiedClientAttributes is busted.
Looks like the correct fix would then to delete the early-out boolean check? I.e.
diff --git a/src/lib/libglemu.js b/src/lib/libglemu.js
index 3089630e6e..8f843db2e6 100644
--- a/src/lib/libglemu.js+++ b/src/lib/libglemu.js@@ -2006,7 +2006,6 @@ var LibraryGLEmulation = {
clientAttributes: [], // raw data, including possible unneeded ones
liveClientAttributes: [], // the ones actually alive in the current computation, sorted
currentRenderer: null, // Caches the currently active FFP emulation renderer, so that it does not have to be re-looked up unless relevant state changes.
- modifiedClientAttributes: false,
clientActiveTexture: 0,
clientColor: null,
usedTexUnitList: [],
@@ -2033,7 +2032,6 @@ var LibraryGLEmulation = {
attrib.pointer = pointer;
attrib.offset = 0;
}
- GLImmediate.modifiedClientAttributes = true;
},
// Renderers
@@ -2888,21 +2886,6 @@ var LibraryGLEmulation = {
// count: number of elements we will draw
// beginEnd: whether we are drawing the results of a begin/end block
prepareClientAttributes(count, beginEnd) {
- // If no client attributes were modified since we were last called, do- // nothing. Note that this does not work for glBegin/End, where we- // generate renderer components dynamically and then disable them- // ourselves, but it does help with glDrawElements/Arrays.- if (!GLImmediate.modifiedClientAttributes) {-#if GL_ASSERTIONS- if ((GLImmediate.stride & 3) != 0) {- warnOnce(`Warning: Rendering from client side vertex arrays where stride (${GLImmediate.stride}) is not a multiple of four! This is not currently supported!`);- }-#endif- GLImmediate.vertexCounter = (GLImmediate.stride * count) / 4; // XXX assuming float- return;- }- GLImmediate.modifiedClientAttributes = false;-
// The role of prepareClientAttributes is to examine the set of
// client-side vertex attribute buffers that user code has submitted, and
// to prepare them to be uploaded to a VBO in GPU memory (since WebGL does
@@ -3129,7 +3112,6 @@ var LibraryGLEmulation = {
GLImmediate.enabledClientAttributes = GLImmediate.enabledClientAttributes_preBegin;
GLImmediate.clientAttributes = GLImmediate.clientAttributes_preBegin;
GLImmediate.currentRenderer = null; // The set of active client attributes changed, we must re-lookup the renderer to use.
- GLImmediate.modifiedClientAttributes = true;
},
glVertex2f: (x, y) => {
@@ -3430,7 +3412,6 @@ var LibraryGLEmulation = {
GL.enableVertexAttribArray(attrib);
#endif
if (GLEmulation.currentVao) GLEmulation.currentVao.enabledClientStates[cap] = 1;
- GLImmediate.modifiedClientAttributes = true;
}
},
glDisableClientState: (cap) => {
@@ -3450,7 +3431,6 @@ var LibraryGLEmulation = {
GL.disableVertexAttribArray(attrib);
#endif
if (GLEmulation.currentVao) delete GLEmulation.currentVao.enabledClientStates[cap];
- GLImmediate.modifiedClientAttributes = true;
}
},
@@ -3584,7 +3564,6 @@ var LibraryGLEmulation = {
GLEmulation.enabledVertexAttribArrays = {};
GLImmediate.enabledClientAttributes = [0, 0];
GLImmediate.totalEnabledClientAttributes = 0;
- GLImmediate.modifiedClientAttributes = true;
if (vao) {
// replay vao
var info = GLEmulation.vaos[vao];
That will be slower for some users, but agreed that correctness here is more important than speed.
@TheTomBritton would you be able to confirm whether the above fixes the issue? And then maybe have an agent generate a test case that verifies the problem and the above fix?
Confirmed — with one important finding: the diff above is necessary but not sufficient for the planar (slow) path.
With the early-out gone, prepareClientAttributes does run again on the next draw, but the restride pass has already overwritten each attribute's pointer to point into the staging buffer:
attr.pointer=start+attr.offset;
so the re-run copy reads its own previous interleaved output back using the original planar strides (and writes it over itself) — still wrong, just differently wrong. Dropping that assignment as well (leaving attr.pointer at the client's own array) fixes it completely. Nothing downstream needs the overwritten value: the renderer works from attr.offset + GLImmediate.stride + GLImmediate.vertexPointer, the only other reader is glGetPointerv (which this change makes answer correctly), and the fast path's attr.offset = attr.pointer - GLImmediate.vertexPointer needs the real pointer anyway if a later draw ends up there.
I had the agent verify this empirically in the app where we found it (three planar tightly-packed static arrays — xy, rgba, st — refilled in place between flushes; our app-side workaround of re-issuing the pointers before every draw was disabled for these runs; same deterministic attract frame each time, headless Chrome/SwiftShader, which reproduces the bug):
Stock emulation (the bug):
Your diff only — still broken:
Your diff + attr.pointer left at the client array — fixed:
Activity
sbc100 commentedon Oct 12, 2026
Thanks Tom. Would you have time to send PR for this?
CC @juj would wrote a lot of this code orginally.
juj commentedon Oct 13, 2026
Agree that yeah, the vertex data should be read when the draw occurs, not when the pointers are bound.
Reading the code with that in mind, it does seem like the state caching boolean logic
GLImmediate.modifiedClientAttributesis busted.Looks like the correct fix would then to delete the early-out boolean check? I.e.
That will be slower for some users, but agreed that correctness here is more important than speed.
@TheTomBritton would you be able to confirm whether the above fixes the issue? And then maybe have an agent generate a test case that verifies the problem and the above fix?
TheTomBritton commentedon Oct 14, 2026
Confirmed — with one important finding: the diff above is necessary but not sufficient for the planar (slow) path.
With the early-out gone,
prepareClientAttributesdoes run again on the next draw, but the restride pass has already overwritten each attribute'spointerto point into the staging buffer:so the re-run copy reads its own previous interleaved output back using the original planar strides (and writes it over itself) — still wrong, just differently wrong. Dropping that assignment as well (leaving
attr.pointerat the client's own array) fixes it completely. Nothing downstream needs the overwritten value: the renderer works fromattr.offset+GLImmediate.stride+GLImmediate.vertexPointer, the only other reader isglGetPointerv(which this change makes answer correctly), and the fast path'sattr.offset = attr.pointer - GLImmediate.vertexPointerneeds the real pointer anyway if a later draw ends up there.I had the agent verify this empirically in the app where we found it (three planar tightly-packed static arrays — xy, rgba, st — refilled in place between flushes; our app-side workaround of re-issuing the pointers before every draw was disabled for these runs; same deterministic attract frame each time, headless Chrome/SwiftShader, which reproduces the bug):
Stock emulation (the bug):
Your diff only — still broken:
Your diff +
attr.pointerleft at the client array — fixed:Reference (stock emulation, app-side workaround on):
PR with both changes plus a browser regression test (planar client arrays refilled in place, two draws, readPixels) coming shortly.