|
<!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> |