← Nikita Seredkin

Writing

Reverse Electron

Developing WebGL without a browser in the loop.

Electron took a browser and wrapped it in a native shell so you could ship web code as a desktop app. This is the opposite move: wrap a native shell around just the part of the browser that draws, so you can develop web code as a native app — and skip the browser entirely while you iterate.

The browser is a bad test harness

Developing WebGL normally means the browser is your test harness. To check a render you boot a page, wait for the context, drive it with something like Playwright, and then try to read pixels back out of an async headless Chromium that was never built to be inspected. It works, but every iteration costs seconds, and the feedback is murky — was that black frame a bug, a timing race, or a screenshot taken one frame too early? For a person that's friction. For a model in a tight loop, it's fatal: too slow and too opaque to converge.

The part nobody notices: ANGLE

When Chrome runs your WebGL on Windows, it doesn't talk to the GPU directly. It goes through ANGLE — a library that translates OpenGL ES, which is what WebGL is, into Direct3D. ANGLE ships as two ordinary DLLs, libEGL and libGLESv2. They aren't Chrome-specific. You can link them into anything.

So I do. My harness is an SDL2 window that creates an ES2 context backed by those exact ANGLE DLLs. The GLES2 my code calls is the same GLES2, through the same translator, that the browser would run. It isn't an approximation of WebGL — it's the same path, minus the browser.

The harness

The whole thing is one reusable file — viz_host.c — that opens the window and ANGLE context, runs a frame loop, snapshots input, compiles shaders, and, the important part, dumps the framebuffer to a PNG with one call. Each thing I'm building is a tiny viz_<name>.c with a main() that calls viz_host_open(), loops on frame_begin/frame_end, and draws. One command builds and runs it — viz.ps1 <name> — gcc, about a second, a window comes up. About 150 lines of host code, reused by every test env.

What it buys

Native tooling. A real compiler, a real debugger, printf, instant rebuilds. No page reload, no devtools, no bundler.

Isolation. Each component runs in its own little executable with nothing else around it — not the whole app, not the browser, not the DOM. Just the code under test and a GL context.

A screenshot is one function call. This is the one that matters: viz_host_screenshot(path, w, h) writes a PNG after you draw. The render becomes verifiable — by me at a glance, or by a model that dumps a frame, reduces it to a number (a mean, a variance, a diff against the last run), and uses that as an oracle. A flicker becomes a nonzero variance; a regression becomes a pixel diff. The loop can finally see.

Same calls, so the same code ships

Because GLES2 is 1:1 with WebGL, the code I develop in the harness is the code that ships. When I built the tiled globe, I wrote and debugged the whole renderer natively — Mercator quadtree, level-of-detail, camera-relative precision, the impostor handoff — as a viz env, then ported it to the web build with zero render-code changes. Native and browser ran identically because, through ANGLE, they were making the same calls.

Why it matters

This is the precondition for the way I actually work — directing models on graphics code. A model builds a component well when the component is small enough to hold in context and there's a fast, mechanical way to check it. The browser-in-the-loop breaks both: validation is slow, and the pixels are hard to read. The native harness fixes both — a bounded component, an isolated executable, a build in a second, and a PNG you can reduce to pass/fail. That's a loop tight enough for the model to converge instead of flail, and tight enough that a whole renderer is a weekend, not a month.

Reverse Electron: don't ship the browser. Borrow its GL layer and leave the rest behind.