Skip to content

LEGACY_GL_EMULATION: planar client-side vertex arrays are not re-read on later glDrawArrays — stale vertex data is drawn #27945

Open

Description

@TheTomBritton

Version

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.restrideBuffer only 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

static float xy[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.

Activity

  1. sbc100 commented on Oct 12, 2026

    @sbc100
    Collaborator

    Thanks Tom. Would you have time to send PR for this?

    CC @juj would wrote a lot of this code orginally.

  2. juj commented on Oct 13, 2026

    @juj
    Collaborator

    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?

  3. TheTomBritton commented on Oct 14, 2026

    @TheTomBritton
    Author

    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):

    stock emulation

    Your diff only — still broken:

    early-out removed only

    Your diff + attr.pointer left at the client array — fixed:

    full fix

    Reference (stock emulation, app-side workaround on):

    reference

    PR with both changes plus a browser regression test (planar client arrays refilled in place, two draws, readPixels) coming shortly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions