Skip to content

Instantly share code, notes, and snippets.

@Whamp
Last active June 3, 2026 23:36
Show Gist options
  • Select an option

  • Save Whamp/875d0f2184a5a4b43e017f439cf6cd00 to your computer and use it in GitHub Desktop.

Select an option

Save Whamp/875d0f2184a5a4b43e017f439cf6cd00 to your computer and use it in GitHub Desktop.
Minimal WebGL generateMipmap atlas repro for xterm.js PR 5987

WebGL generateMipmap atlas repro

Minimal standalone repro harness for the xterm.js glyph-atlas mipmap issue fixed in xtermjs/xterm.js#5987.

It does not import xterm.js. It creates separate canvases for WebGL2 and WebGL1 so the two context checks do not interfere with each other.

  1. creates a 512x512 canvas-backed RGBA texture, matching the glyph atlas shape;
  2. draws dense terminal-like monospace glyph/color churn into that atlas canvas;
  3. uploads it with gl.texImage2D(..., atlasCanvas);
  4. calls gl.generateMipmap(gl.TEXTURE_2D);
  5. checks gl.getError() and context loss;
  6. compares against the fixed path: TEXTURE_MIN_FILTER=LINEAR, TEXTURE_MAG_FILTER=LINEAR, no mipmap generation.

Run

Serve the directory and open it in Chromium on the affected Linux/ANGLE machine:

python -m http.server 8765
# open http://127.0.0.1:8765/index.html

The page prints:

  • WebGL version
  • masked and unmasked renderer/vendor, if available
  • MAX_TEXTURE_SIZE
  • atlas size
  • errors after the old mipmap path
  • errors after a repeated stress path
  • errors after the fixed no-mipmap path
  • whether webglcontextlost fired

Expected affected result

On affected Chromium/ANGLE Linux driver paths, the old/stress path should report INVALID_OPERATION after generateMipmap, while the fixed path should report NO_ERROR.

On unaffected machines, the page should say RESULT: not reproduced on this browser/driver.

Local verification

On my desktop this harness does not reproduce the failure:

UNMASKED_RENDERER_WEBGL: ANGLE (AMD, AMD Radeon 780M Graphics (radeonsi phoenix ACO), OpenGL ES 3.2)
MAX_TEXTURE_SIZE: 16384
atlas size: 512x512
after generateMipmap: NO_ERROR
stress result: NO_ERROR after 2048 iterations
after fixed upload: NO_ERROR
RESULT: not reproduced on this browser/driver

The PR discussion had a separate affected report on AMD Radeon 680M / radeonsi via ANGLE where generateMipmap returned GL_INVALID_OPERATION for a 512x512 atlas.

<!doctype html>
<meta charset="utf-8">
<title>WebGL generateMipmap atlas repro</title>
<style>
body { font: 14px/1.4 system-ui, sans-serif; margin: 24px; max-width: 900px; }
canvas { border: 1px solid #ccc; margin: 8px 0; image-rendering: pixelated; }
pre { background: #111; color: #eee; padding: 12px; overflow: auto; white-space: pre-wrap; }
.fail { color: #b00020; font-weight: 700; }
.pass { color: #0b6b22; font-weight: 700; }
</style>
<h1>WebGL generateMipmap atlas repro</h1>
<p>
Minimal reproduction for Chromium/ANGLE returning <code>GL_INVALID_OPERATION</code>
when generating mipmaps for a small 512×512 RGBA texture uploaded from a canvas,
matching the xterm.js glyph-atlas path.
</p>
<canvas id="glCanvas" width="256" height="128"></canvas>
<canvas id="atlasCanvas" width="512" height="512"></canvas>
<pre id="out">Running…</pre>
<script>
(() => {
const out = document.getElementById('out');
const atlasCanvas = document.getElementById('atlasCanvas');
const atlasCtx = atlasCanvas.getContext('2d');
function log(line = '') {
out.textContent += line + '\n';
console.log(line);
}
function enumName(gl, value) {
for (const key in gl) {
if (gl[key] === value && key === key.toUpperCase()) {
return key;
}
}
return `0x${value.toString(16).padStart(8, '0')}`;
}
function drainErrors(gl, label) {
const errors = [];
let err;
while ((err = gl.getError()) !== gl.NO_ERROR) {
errors.push(enumName(gl, err));
}
log(`${label}: ${errors.length ? errors.join(', ') : 'NO_ERROR'}`);
return errors;
}
const TERMINAL_TEXT = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
function fillTerminalLikeAtlas(frame = 0) {
atlasCtx.clearRect(0, 0, atlasCanvas.width, atlasCanvas.height);
atlasCtx.fillStyle = 'rgba(0,0,0,0)';
atlasCtx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height);
atlasCtx.font = '12px monospace';
atlasCtx.textBaseline = 'top';
// Simulate terminal glyph-atlas churn: dense monospace glyphs, many foreground
// colors, repeated redraws like a scrolling TUI. This is still not xterm.js; it
// only reproduces the canvas-backed atlas upload/mipmap path.
for (let row = 0; row < 32; row++) {
for (let col = 0; col < 4; col++) {
const x = col * 128;
const y = row * 16;
const r = (frame * 3 + row * 7 + col * 29) & 255;
const g = (frame * 5 + row * 11 + col * 31) & 255;
const b = (frame * 7 + row * 13 + col * 37) & 255;
const offset = (frame + row + col) % TERMINAL_TEXT.length;
atlasCtx.fillStyle = `rgb(${r}, ${g}, ${b})`;
atlasCtx.fillText((TERMINAL_TEXT + TERMINAL_TEXT).slice(offset, offset + 20), x, y);
}
}
}
function createTexture(gl, withLinearFilter) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (withLinearFilter) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas);
return texture;
}
function runForContext(contextName) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 128;
document.body.insertBefore(canvas, out);
const gl = canvas.getContext(contextName, { antialias: false, preserveDrawingBuffer: true });
log(`## ${contextName}`);
if (!gl) {
log('Context unavailable');
log();
return;
}
let contextLost = false;
canvas.addEventListener('webglcontextlost', event => {
contextLost = true;
event.preventDefault();
log('webglcontextlost fired');
}, { once: true });
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
log(`VERSION: ${gl.getParameter(gl.VERSION)}`);
log(`VENDOR: ${gl.getParameter(gl.VENDOR)}`);
log(`RENDERER: ${gl.getParameter(gl.RENDERER)}`);
if (debugInfo) {
log(`UNMASKED_VENDOR_WEBGL: ${gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)}`);
log(`UNMASKED_RENDERER_WEBGL: ${gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)}`);
} else {
log('WEBGL_debug_renderer_info: unavailable');
}
log(`MAX_TEXTURE_SIZE: ${gl.getParameter(gl.MAX_TEXTURE_SIZE)}`);
log(`atlas size: ${atlasCanvas.width}×${atlasCanvas.height}`);
drainErrors(gl, 'initial errors');
log('old xterm.js path: canvas texImage2D + generateMipmap');
const mipmapTexture = createTexture(gl, false);
drainErrors(gl, 'after texImage2D');
gl.generateMipmap(gl.TEXTURE_2D);
const mipmapErrors = drainErrors(gl, 'after generateMipmap');
log(`contextLost: ${contextLost || gl.isContextLost()}`);
log('stress path: repeated canvas texImage2D + generateMipmap');
let stressError = null;
const stressIterations = 2048;
for (let i = 0; i < stressIterations; i++) {
fillTerminalLikeAtlas(i);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas);
gl.generateMipmap(gl.TEXTURE_2D);
const err = gl.getError();
if (err !== gl.NO_ERROR) {
const errors = [enumName(gl, err)];
let extraErr;
while ((extraErr = gl.getError()) !== gl.NO_ERROR) {
errors.push(enumName(gl, extraErr));
}
stressError = { iteration: i, error: errors.join(', ') };
break;
}
}
log(`stress result: ${stressError ? `${stressError.error} at iteration ${stressError.iteration}` : `NO_ERROR after ${stressIterations} iterations`}`);
log(`contextLost after stress: ${contextLost || gl.isContextLost()}`);
drainErrors(gl, 'errors before fixed path');
gl.deleteTexture(mipmapTexture);
log('fixed path: canvas texImage2D + LINEAR min/mag filter + no generateMipmap');
const linearTexture = createTexture(gl, true);
const linearErrors = drainErrors(gl, 'after fixed upload');
gl.deleteTexture(linearTexture);
const failed = (mipmapErrors.includes('INVALID_OPERATION') || stressError?.error === 'INVALID_OPERATION') && linearErrors.length === 0;
log(`RESULT: ${failed ? 'REPRODUCED generateMipmap INVALID_OPERATION' : 'not reproduced on this browser/driver'}`);
log();
}
out.textContent = '';
fillTerminalLikeAtlas();
runForContext('webgl2');
runForContext('webgl');
})();
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment