fix(3d): blank viewer after raytracing round-trip — owner-context FFP routing + VAO isolation (gl1)

Switching OpenGL → raytracing → OpenGL could leave the viewer showing only the
background gradient, with mid-session "[gl1] WebGL context changed" thrash and
INVALID_OPERATION storms on BOTH WebGL contexts. Traced mechanism: the shim's
FFP draw routing keyed on one process-global client-array flag; an interrupted
fixed-function window (MODEL_3D::BeginDrawMulti loops, or the switch-back
reload re-recording display lists with the GL context lock released across
JSPI suspensions) left it set, after which the raytracer blit's and the 2D
GAL's glDrawArrays were routed through the FFP pipeline — and one misrouted
draw permanently repointed the VICTIM's own VAO attributes at shim buffers
(the blit's attribute 0 collides with ATTR_POSITION), so both stayed
broken/blank even after the flag cleared.

Shim fixes (wasm/gl1):
- Owner-context routing gate: __wrap_glDrawArrays/Elements route into the FFP
  pipeline only under the shim's owner context (adopted at the first FFP
  client-state mutation or programSync in a context); foreign-context draws
  always pass through — the 2D GAL can never be misrouted and the context
  guard can never thrash.
- VAO isolation (ScopedDefaultVAO): draw executors do their attribute setup on
  VAO 0 and restore the caller's binding — a misrouted draw can no longer
  corrupt the caller.
- contextSync() resets the whole client-array mirror on a context change
  (enables/pointers/VBO names all described the dead context).

kicad pointer bump (d6e3dc1a87a): blit preamble disables the four client
arrays (same-context firewall) + DoRePaint hidden-parent early return now
clears m_is_currently_painting like its six siblings (a standalone
sufficient cause of a permanently blank viewer).

TDD (each observed red before its fix, green after; harness = authoritative):
- T1 VAO corruption, T2 foreign-context routing + guard thrash, T3 stale
  client-state surviving context recreation — tests/e2e/3d-webgl.spec.ts over
  new harness choreography (appQuad/ffpMakeStale/createSecondContext/
  quadDrawFresh). Parity stays 47/47 with zero drift.
- tests/kicad/3d-viewer-engine-toggle.spec.ts (new, CI-skipped like the
  deadlock spec): real round-trip happy-path gate — board re-renders, zero
  [gl1] lines, zero INVALID_OPERATION. (The raytraced image itself never
  displays on the wasm build — the pre-existing inert-toggle KNOWN ISSUE in
  3d-viewer-deadlock.spec.ts, out of scope here; the engine switch and the
  poisoning reload path run regardless.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-08-25 11:24:26 +02:00
commit daaff1f973
13 changed files with 542 additions and 8 deletions

View file

@ -109,6 +109,172 @@ int runScenario( int aIndex )
return 0;
}
// ---------------------------------------------------------------------------
// Engine-toggle regression scaffolding: model a "modern-GL consumer" (the
// raytracer blit / the 2D GAL) sharing the process with the FFP shim.
// ---------------------------------------------------------------------------
// A minimal GLSL quad drawer with a PERSISTENT VAO (the victim). Mirrors the
// raytracer blit: attribute location 0 (colliding with the shim's
// ATTR_POSITION), own VBO, own program.
static GLuint g_appVAO = 0, g_appVBO = 0, g_appProg = 0;
static GLuint compileMini( GLenum type, const char* src )
{
GLuint s = glCreateShader( type );
glShaderSource( s, 1, &src, nullptr );
glCompileShader( s );
return s;
}
extern "C"
{
// (Re)build the persistent app quad in the CURRENT context. Green full-screen
// quad on attribute 0. Returns 0 on success.
EMSCRIPTEN_KEEPALIVE
int appQuadInit()
{
static const char* VS = "#version 300 es\nlayout(location=0) in vec2 p;"
"void main(){ gl_Position = vec4(p,0.,1.); }";
static const char* FS = "#version 300 es\nprecision mediump float;"
"out vec4 c; void main(){ c = vec4(0.,1.,0.,1.); }";
GLuint vs = compileMini( GL_VERTEX_SHADER, VS );
GLuint fs = compileMini( GL_FRAGMENT_SHADER, FS );
GLuint prog = glCreateProgram();
glAttachShader( prog, vs );
glAttachShader( prog, fs );
glLinkProgram( prog );
glDeleteShader( vs );
glDeleteShader( fs );
GLint linked = GL_FALSE;
glGetProgramiv( prog, GL_LINK_STATUS, &linked );
if( !linked )
return -1;
static const float quad[] = { -1.f, -1.f, 1.f, -1.f, -1.f, 1.f,
-1.f, 1.f, 1.f, -1.f, 1.f, 1.f };
g_appProg = prog;
glGenVertexArrays( 1, &g_appVAO );
glBindVertexArray( g_appVAO );
glGenBuffers( 1, &g_appVBO );
glBindBuffer( GL_ARRAY_BUFFER, g_appVBO );
glBufferData( GL_ARRAY_BUFFER, sizeof( quad ), quad, GL_STATIC_DRAW );
glEnableVertexAttribArray( 0 );
glVertexAttribPointer( 0, 2, GL_FLOAT, GL_FALSE, 0, nullptr );
glBindVertexArray( 0 );
glBindBuffer( GL_ARRAY_BUFFER, 0 );
return (int) glGetError();
}
// Draw the persistent app quad exactly like the blit does: bind ITS VAO, its
// program, glDrawArrays. aClearFirst=1 clears to black before drawing so the
// framebuffer afterwards shows only this draw's output. Returns glGetError.
EMSCRIPTEN_KEEPALIVE
int appQuadDraw( int aClearFirst )
{
while( glGetError() != GL_NO_ERROR ) {}
if( aClearFirst )
{
glViewport( 0, 0, CAPTURE_WIDTH, CAPTURE_HEIGHT );
glClearColor( 0.f, 0.f, 0.f, 1.f );
glClear( GL_COLOR_BUFFER_BIT );
}
glUseProgram( g_appProg );
glBindVertexArray( g_appVAO );
glDrawArrays( GL_TRIANGLES, 0, 6 );
glBindVertexArray( 0 );
glUseProgram( 0 );
glFinish();
return (int) glGetError();
}
// Leave the FFP mirror in the poisoned shape the engine switch can produce:
// GL_VERTEX_ARRAY enabled with a (small) VBO captured by glVertexPointer —
// the MODEL_3D::BeginDrawMulti window state.
EMSCRIPTEN_KEEPALIVE
void ffpMakeStale()
{
static GLuint tinyVBO = 0;
static const float tri[] = { 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f };
if( !tinyVBO )
{
glGenBuffers( 1, &tinyVBO );
glBindBuffer( GL_ARRAY_BUFFER, tinyVBO );
glBufferData( GL_ARRAY_BUFFER, sizeof( tri ), tri, GL_STATIC_DRAW );
}
else
glBindBuffer( GL_ARRAY_BUFFER, tinyVBO );
glVertexPointer( 3, GL_FLOAT, 0, nullptr ); // captures tinyVBO in the mirror
glEnableClientState( GL_VERTEX_ARRAY );
glBindBuffer( GL_ARRAY_BUFFER, 0 );
}
EMSCRIPTEN_KEEPALIVE
void ffpClearStale()
{
glDisableClientState( GL_VERTEX_ARRAY );
}
// A second live WebGL context on its own canvas — the harness model of the 2D
// editor's GAL context coexisting with the 3D viewer's.
static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE g_context2 = 0;
EMSCRIPTEN_KEEPALIVE
int createSecondContext()
{
EM_ASM( {
if( !document.getElementById( "canvas2" ) )
{
var c = document.createElement( "canvas" );
c.id = "canvas2";
c.width = 320; c.height = 240;
document.body.appendChild( c );
if( typeof specialHTMLTargets !== 'undefined' )
specialHTMLTargets['#canvas2'] = c;
}
} );
EmscriptenWebGLContextAttributes attrs;
emscripten_webgl_init_context_attributes( &attrs );
attrs.majorVersion = 2;
attrs.minorVersion = 0;
attrs.preserveDrawingBuffer = true;
g_context2 = emscripten_webgl_create_context( "#canvas2", &attrs );
return g_context2 > 0 ? 0 : -1;
}
// Switch the current context: 1 = the main harness context, 2 = the second.
EMSCRIPTEN_KEEPALIVE
int useContext( int aWhich )
{
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE h = ( aWhich == 2 ) ? g_context2 : g_context;
if( h <= 0 )
return -1;
return emscripten_webgl_make_context_current( h ) == EMSCRIPTEN_RESULT_SUCCESS ? 0 : -2;
}
// Build + draw + delete a fresh GLSL quad entirely in the CURRENT context (no
// persistent state — safe under any context). Returns glGetError after draw.
EMSCRIPTEN_KEEPALIVE
int quadDrawFresh()
{
GLuint savedVAO = g_appVAO, savedVBO = g_appVBO, savedProg = g_appProg;
g_appVAO = g_appVBO = g_appProg = 0;
int rc = appQuadInit();
if( rc == 0 )
rc = appQuadDraw( 1 );
glDeleteVertexArrays( 1, &g_appVAO );
glDeleteBuffers( 1, &g_appVBO );
glDeleteProgram( g_appProg );
g_appVAO = savedVAO; g_appVBO = savedVBO; g_appProg = savedProg;
return rc;
}
} // extern "C"
// Destroy the current WebGL context and mint a fresh one on a fresh canvas
// element — the harness model of the app's 3D-viewer close/reopen (~wxGLCanvas
// destroys its context AND its DOM canvas; a new frame creates new ones).