readPixels() into a typed array makes the CPU wait until the GPU has drawn everything queued before it. With a buffer bound to PIXEL_PACK_BUFFER, the same call only records a copy into GPU memory and returns at once; a fence (fenceSync()) then tells you when the copy is done, and getBufferSubData() fetches it without a stall:
const canvas = Object.assign(document.createElement('canvas'), { width: 600, height: 100 });
const gl = canvas.getContext('webgl2'), bytes = 600 * 100 * 4;
gl.clearColor(...GLH.rgb(GLH.covers[2]), 1); gl.clear(gl.COLOR_BUFFER_BIT); // book 3's color
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, gl.createBuffer());
gl.bufferData(gl.PIXEL_PACK_BUFFER, bytes, gl.STREAM_READ);
gl.readPixels(0, 0, 600, 100, gl.RGBA, gl.UNSIGNED_BYTE, 0); // 0 = offset into the buffer
const fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
gl.flush(); // make sure the fence is submitted
let frames = 0;
while (gl.clientWaitSync(fence, 0, 0) === gl.TIMEOUT_EXPIRED) { // poll, never block
frames++; await new Promise(requestAnimationFrame);
}
const pixels = new Uint8Array(bytes);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixels);
console.log(`ready after ${frames} frame(s); first pixel =`, pixels.slice(0, 4).join(', '));Output
ready after 3 frame(s); first pixel = 224, 154, 16, 255
That is Salt and Saffron's #e09a10, after 1 to 5 frames across runs. WebGL caps clientWaitSync()'s timeout at zero (1000 ns gave INVALID_OPERATION), so poll once a frame. Use this for readbacks you repeat every frame.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.row { display: flex; gap: 10px; max-width: 600px; align-items: center; }
canvas { width: 480px; height: 160px; flex: none; }
#swatch { width: 90px; height: 90px; border: 1px solid #999; }
pre { max-width: 600px; margin: 4px 0; font: 11px monospace; }
</style>
<div class="row"><canvas id="c" width="480" height="160"></canvas><div id="swatch"></div></div>
<pre id="out"></pre>
<script>
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
void main() { gl_Position = vec4(gl_VertexID == 1 ? 3.0 : -1.0, gl_VertexID == 2 ? 3.0 : -1.0, 0.0, 1.0); }`],
[gl.FRAGMENT_SHADER, `#version 300 es
precision highp float; uniform vec2 uMarker; uniform float uTime; out vec4 c;
const vec3 covers[6] = vec3[](vec3(0.12,0.37,0.55), vec3(0.36,0.25,0.6), vec3(0.88,0.6,0.06), vec3(0.25,0.49,0.23), vec3(0.71,0.27,0.18), vec3(0.16,0.62,0.56));
void main() {
vec2 p = gl_FragCoord.xy;
int book = int(mod(p.x + uTime * 40.0, 480.0) / 80.0); // scrolling cover strip
vec3 color = covers[book] * (0.8 + 0.2 * p.y / 160.0);
float d = distance(p, uMarker);
if (abs(d - 9.0) < 1.5) color = vec3(1.0); // the marker ring
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.bindVertexArray(gl.createVertexArray());
// The pixel buffer object: readPixels into it only records a GPU-side copy.
const pbo = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
gl.bufferData(gl.PIXEL_PACK_BUFFER, 4, gl.STREAM_READ);
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
let pending = null, reads = 0, totalWait = 0;
const pixel = new Uint8Array(4);
function frame(ms) {
const marker = [240 + 200 * Math.sin(ms * 0.0007), 80 + 50 * Math.sin(ms * 0.0013)];
gl.uniform2fv(gl.getUniformLocation(program, 'uMarker'), marker);
gl.uniform1f(gl.getUniformLocation(program, 'uTime'), ms / 1000);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (pending && gl.clientWaitSync(pending.fence, 0, 0) !== gl.TIMEOUT_EXPIRED) { // poll, never block
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixel); // ready: no stall
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
gl.deleteSync(pending.fence);
reads++; totalWait += pending.frames;
document.getElementById('swatch').style.background = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`;
document.getElementById('out').textContent =
`read ${reads}: pixel (${pixel.slice(0, 3).join(', ')}) arrived ${pending.frames} frame(s) after it was requested, average ${(totalWait / reads).toFixed(1)}`;
pending = null;
}
if (!pending) { // start the next asynchronous read
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
gl.readPixels(Math.round(marker[0]), Math.round(marker[1]), 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, 0); // 0: offset in the buffer
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
pending = { fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0), frames: 0 };
gl.flush(); // make sure the fence is submitted
} else pending.frames++;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>