Image bytes are sRGB-encoded: byte 128 is not half the light of 255 but about a fifth, since the encoding spends more values on dark tones, as the eye does, while lighting math needs linear light. An SRGB8_ALPHA8 texture is decoded to linear as it is sampled, before filtering; RGBA8 returns the bytes divided by 255:
const sample = (format, byte) => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
gl.texImage2D(gl.TEXTURE_2D, 0, format, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array([byte, byte, byte, 255]));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.activeTexture(gl.TEXTURE1); // GLH.evaluate's target goes to unit 1
return GLH.evaluate(gl, 'result = texture(uTexture, vec2(0.5));',
'uniform sampler2D uTexture;')[0].toFixed(4);
};
for (const byte of [64, 128, 188, 255]) {
const [plain, srgb] = [gl.RGBA8, gl.SRGB8_ALPHA8].map((format) => sample(format, byte));
console.log(byte, 'RGBA8', plain, 'SRGB8_ALPHA8', srgb);
}64 RGBA8 0.2510 SRGB8_ALPHA8 0.0513 128 RGBA8 0.5020 SRGB8_ALPHA8 0.2158 188 RGBA8 0.7373 SRGB8_ALPHA8 0.5039 255 RGBA8 1.0000 SRGB8_ALPHA8 1.0000
The canvas expects sRGB values, so a linear result must be encoded again, roughly pow(color, vec3(1.0 / 2.2)). The shelf so far lights sRGB colors directly, a common shortcut, with srgb: false; Normal Maps and PBR's PBR shader works in linear light. Data textures (normals, roughness) are never sRGB. Other handy WebGL 2 formats: R8 and RG8 for masks, RGBA16F for HDR, and the integer formats of texture().
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.wrap { position: relative; max-width: 600px; }
canvas { display: block; width: 100%; }
.lbl { position: absolute; left: 6px; font-family: monospace; color: #fff; text-shadow: 0 0 3px #000; }
table { max-width: 600px; width: 100%; border-collapse: collapse; font-family: monospace; margin-top: 4px; }
td, th { padding: 2px 6px; border-bottom: 1px solid #e3ddd0; text-align: left; }
</style>
<div class="wrap">
<canvas id="c" width="600" height="210"></canvas>
<div class="lbl" style="top:6%">RGBA8: bytes / 255</div>
<div class="lbl" style="top:39%">SRGB8_ALPHA8: decoded to linear light</div>
<div class="lbl" style="top:72%">SRGB8_ALPHA8, then pow(c, 1/2.2) for the canvas</div>
</div>
<table id="t"></table>
<script>
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPos; out vec2 vUV;
void main() { vUV = aPos; gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }`],
[gl.FRAGMENT_SHADER, `#version 300 es
precision highp float; in vec2 vUV; uniform sampler2D uRamp; uniform bool uEncode; out vec4 c;
void main() {
vec3 color = texture(uRamp, vec2(vUV.x, 0.5)).rgb;
if (uEncode) color = pow(color, vec3(1.0 / 2.2)); // back to (roughly) sRGB
c = vec4(color, 1.0);
}`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
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);
// One ramp of 16 bytes: 0, 17, 34 ... 255, stored twice with different internal formats.
const bytes = new Uint8Array(16 * 4);
for (let i = 0; i < 16; i++) bytes.set([i * 17, i * 17, i * 17, 255], i * 4);
function ramp(format) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, format, 16, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
return t;
}
const plain = ramp(gl.RGBA8), srgb = ramp(gl.SRGB8_ALPHA8);
const uEncode = gl.getUniformLocation(program, 'uEncode');
[[plain, false], [srgb, false], [srgb, true]].forEach(([t, encode], row) => {
gl.bindTexture(gl.TEXTURE_2D, t);
gl.uniform1i(uEncode, encode);
gl.viewport(0, 140 - row * 70, 600, 68);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Read the sampled values back through a float render target, for the table.
gl.getExtension('EXT_color_buffer_float');
const target = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, target);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, 16, 1);
gl.bindFramebuffer(gl.FRAMEBUFFER, gl.createFramebuffer());
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target, 0);
gl.viewport(0, 0, 16, 1);
gl.uniform1i(uEncode, 0);
const read = (t) => { gl.bindTexture(gl.TEXTURE_2D, t); gl.drawArrays(gl.TRIANGLES, 0, 6);
const out = new Float32Array(64); gl.readPixels(0, 0, 16, 1, gl.RGBA, gl.FLOAT, out); return out; };
const a = read(plain), b = read(srgb);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
document.getElementById('t').innerHTML = '<tr><th>byte</th><th>RGBA8</th><th>SRGB8_ALPHA8</th></tr>' +
[4, 8, 11, 15].map(i => `<tr><td>${i * 17}</td><td>${a[i * 4].toFixed(4)}</td><td>${b[i * 4].toFixed(4)}</td></tr>`).join('');
</script>