WebGL 1 treats a texture whose width or height is not a power of two (NPOT), such as BookNest's 256 x 384 covers, as second class: no mipmaps, and CLAMP_TO_EDGE on both axes, or it samples black. WebGL 2 drops both rules. A probe sampled a 3 x 3 texture of value 200 through the same GLSL ES 1.00 shader in both versions. In WebGL 1, generateMipmap() failed with INVALID_OPERATION and REPEAT sampled 0; only CLAMP_TO_EDGE on S and T gave 200 (a first draft set only WRAP_S and still got black). In WebGL 2 every case gave 200. If you must support WebGL 1, resize images to powers of two on a 2D canvas before upload, as older engines did. Under WebGL 2 the remaining reasons for power-of-two sizes are atlases and compressed formats, whose 4 x 4 blocks want dimensions divisible by four.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; }
.names { display: flex; max-width: 600px; text-align: center; font-family: monospace; font-size: 10px; }
.names div { flex: 1; padding: 2px; }
p { max-width: 600px; margin: 2px 0 8px; font: bold 11px system-ui; }
</style>
<p>WebGL 1</p><canvas id="gl1" width="600" height="110"></canvas>
<p>WebGL 2</p><canvas id="gl2" width="600" height="110"></canvas>
<div class="names"><div>CLAMP S + T</div><div>REPEAT</div><div>CLAMP on S only</div><div>generateMipmap()</div></div>
<script>
// A 3 x 5 texture (NPOT on both axes): a tiny striped cover.
const W = 3, H = 5, pixels = new Uint8Array(W * H * 4);
for (let i = 0; i < W * H; i++) pixels.set(i % 2 ? [224, 154, 16, 255] : [31, 95, 139, 255], i * 4);
function run(id, version) {
const gl = document.getElementById(id).getContext(version);
const program = gl.createProgram();
// The same GLSL ES 1.00 shaders work in both versions.
for (const [type, src] of [[gl.VERTEX_SHADER, `attribute vec2 aPos; uniform float uScale; varying vec2 vUV;
void main() { vUV = aPos * uScale; gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }`],
[gl.FRAGMENT_SHADER, `precision mediump float; varying vec2 vUV; uniform sampler2D uTex;
void main() { gl_FragColor = texture2D(uTex, vUV); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); gl.attachShader(program, s);
}
gl.bindAttribLocation(program, 0, 'aPos');
gl.linkProgram(program); gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
const T = gl.TEXTURE_2D, errors = [];
const setups = [
[1, { WRAP_S: 'CLAMP_TO_EDGE', WRAP_T: 'CLAMP_TO_EDGE', MIN_FILTER: 'NEAREST' }],
[2, { WRAP_S: 'REPEAT', WRAP_T: 'REPEAT', MIN_FILTER: 'NEAREST' }],
[1, { WRAP_S: 'CLAMP_TO_EDGE', MIN_FILTER: 'NEAREST' }], // T left at the default REPEAT
[1, { WRAP_S: 'CLAMP_TO_EDGE', WRAP_T: 'CLAMP_TO_EDGE', MIN_FILTER: 'NEAREST_MIPMAP_NEAREST', mipmap: true }]];
setups.forEach(([scale, params], i) => {
gl.bindTexture(T, gl.createTexture());
gl.texImage2D(T, 0, gl.RGBA, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
gl.texParameteri(T, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
for (const [k, v] of Object.entries(params)) if (k !== 'mipmap') gl.texParameteri(T, gl['TEXTURE_' + k], gl[v]);
if (params.mipmap) { gl.generateMipmap(T); errors.push(gl.getError()); } // WebGL 1: INVALID_OPERATION for NPOT
gl.uniform1f(gl.getUniformLocation(program, 'uScale'), scale);
gl.viewport(i * 150 + 45, 5, 60, 100);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
return errors[0];
}
const e1 = run('gl1', 'webgl'), e2 = run('gl2', 'webgl2');
document.querySelector('.names').insertAdjacentHTML('afterend',
`<p style="font-weight:normal;font-family:monospace">generateMipmap() error: WebGL 1 ${e1} (1282 = INVALID_OPERATION), WebGL 2 ${e2}</p>`);
</script>