Images are stored gamma-encoded (sRGB), but lighting and blending math must happen in linear light. An -srgb format converts for you: sampling decodes the stored byte to linear, and writing to a render target encodes linear back to sRGB. Measured here with a compute shader's textureLoad() and a render-pass clear, the byte 128 reads as 0.5020 from rgba8unorm but 0.2158 from rgba8unorm-srgb, and clearing to 0.5 stores 127 and 188 respectively: mid-grey in an image is only 21.6% linear light, and linear 0.5 needs byte 188. (0.5 x 255 = 127.5 sits exactly between two bytes; this GPU rounded down.) Use -srgb formats for color images and displayed render targets, and plain unorm for data such as normal maps, masks and lookup tables. The canvas is usually bgra8unorm (Canvas and Presentation), so render through a bgra8unorm-srgb view listed in viewFormats, or encode in the shader. Uploads convert too: copyExternalImageToTexture()'s destination takes colorSpace ('srgb' or 'display-p3') and premultipliedAlpha, and its source takes flipY.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="330"></canvas>
<canvas id="labels" width="600" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
// A ramp of linear light values 0..1, in ten steps; the target format decides how it is stored.
const ramp = /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[v];
return vec4f(p, 0, 1);
}
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let x = floor((pos.x - 20) / 56) / 9; // 0, 1/9, ..., 1
return vec4f(vec3f(x) * vec3f(1.0, 0.92, 0.8), 1);
}`;
// Reads byte 128 from two textures that differ only in format.
const probe = /* wgsl */ `
@group(0) @binding(0) var plain: texture_2d<f32>;
@group(0) @binding(1) var srgb: texture_2d<f32>;
@group(0) @binding(2) var<storage, read_write> out: array<f32>;
@compute @workgroup_size(1) fn main() {
out[0] = textureLoad(plain, vec2u(0), 0).r; // rgba8unorm: 128 / 255
out[1] = textureLoad(srgb, vec2u(0), 0).r; // rgba8unorm-srgb: decoded to linear
}`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat(); // bgra8unorm or rgba8unorm
const srgbFormat = `${format}-srgb`;
context.configure({ device, format, viewFormats: [srgbFormat] }); // allow an sRGB view of the canvas
const T = GPUTextureUsage, B = GPUBufferUsage;
// 1. Measure: decoding on read, and encoding on write (a cleared render target read back).
const tex = (f) => device.createTexture({ size: [1, 1], format: f, usage: T.TEXTURE_BINDING | T.COPY_DST | T.COPY_SRC | T.RENDER_ATTACHMENT });
const plain = tex('rgba8unorm'), srgb = tex('rgba8unorm-srgb');
for (const t of [plain, srgb]) device.queue.writeTexture({ texture: t }, new Uint8Array([128, 128, 128, 255]), {}, [1, 1]);
const out = device.createBuffer({ size: 8, usage: B.STORAGE | B.COPY_SRC });
const read = device.createBuffer({ size: 8 + 512, usage: B.COPY_DST | B.MAP_READ });
const compute = device.createComputePipeline({ layout: 'auto', compute: { module: device.createShaderModule({ code: probe }) } });
let encoder = device.createCommandEncoder();
const cp = encoder.beginComputePass();
cp.setPipeline(compute);
cp.setBindGroup(0, device.createBindGroup({ layout: compute.getBindGroupLayout(0), entries: [
{ binding: 0, resource: plain.createView() }, { binding: 1, resource: srgb.createView() }, { binding: 2, resource: { buffer: out } }] }));
cp.dispatchWorkgroups(1);
cp.end();
encoder.copyBufferToBuffer(out, 0, read, 0, 8);
[plain, srgb].forEach((t, i) => { // clear each to linear 0.5, then copy the byte out
encoder.beginRenderPass({ colorAttachments: [{ view: t.createView(), clearValue: [0.5, 0.5, 0.5, 1], loadOp: 'clear', storeOp: 'store' }] }).end();
encoder.copyTextureToBuffer({ texture: t }, { buffer: read, offset: 8 + i * 256, bytesPerRow: 256 }, [1, 1]);
});
device.queue.submit([encoder.finish()]);
await read.mapAsync(GPUMapMode.READ);
const [decodedPlain, decodedSrgb] = new Float32Array(read.getMappedRange(0, 8));
const bytes = new Uint8Array(read.getMappedRange(8, 512));
const storedPlain = bytes[0], storedSrgb = bytes[256];
read.unmap();
// 2. Draw the ramp twice: through the plain canvas view and through an sRGB view of it.
const module = device.createShaderModule({ code: ramp });
const pipeline = (f) => device.createRenderPipeline({ layout: 'auto', vertex: { module }, fragment: { module, targets: [{ format: f }] } });
const plainPipeline = pipeline(format), srgbPipeline = pipeline(srgbFormat);
const texture = context.getCurrentTexture();
encoder = device.createCommandEncoder();
[[plainPipeline, format, 40], [srgbPipeline, srgbFormat, 130]].forEach(([p, f, y], i) => {
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: texture.createView({ format: f }),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: i === 0 ? 'clear' : 'load', storeOp: 'store' }] });
pass.setPipeline(p);
pass.setViewport(0, y, 600, 56, 0, 1);
pass.setScissorRect(20, y, 560, 56);
pass.draw(3);
pass.end();
});
device.queue.submit([encoder.finish()]);
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText(`linear 0 ... 1 written through a ${format} view: stored as is (too dark)`, 20, 32);
ink.fillText(`the same values through a ${srgbFormat} view: encoded to sRGB`, 20, 122);
ink.font = '13px system-ui, sans-serif';
ink.fillText(`byte 128 read with textureLoad: rgba8unorm ${decodedPlain.toFixed(4)}, rgba8unorm-srgb ${decodedSrgb.toFixed(4)}`, 20, 228);
ink.fillText(`clear to linear 0.5, byte stored: rgba8unorm ${storedPlain}, rgba8unorm-srgb ${storedSrgb}`, 20, 252);
ink.fillStyle = '#444'; ink.font = '12px system-ui, sans-serif';
ink.fillText('Mid-grey in an image is only about 21.6% linear light. Use -srgb formats for colour images and', 20, 290);
ink.fillText('displayed targets; plain unorm for data such as normal maps, masks and lookup tables.', 20, 308);
}
main();
</script>