Pointer events report CSS pixels from the top left; WebGL counts drawing-buffer pixels from the bottom left, and clip space from -1 to 1. locate() scales by canvas size over CSS size (2 on high-density screens) and flips y after rounding down: on a 1240 x 520 canvas shown at 620 x 260, client (0, 0) gave pixel (0, 519) and clip (-1, 1), and the center (310, 130) gave pixel (620, 259) and clip (0, 0). Older code used rect.bottom - y in CSS pixels: off by one row, and wrong whenever the CSS size differs from the canvas's width and height.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
canvas { display: block; width: 100%; max-width: 600px; cursor: none; }
pre { max-width: 600px; margin: 4px 0; font: 11px monospace; }
</style>
<canvas id="c" width="1200" height="520"></canvas>
<pre id="out"></pre>
<script>
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
// CSS pixels from the top left -> buffer pixels from the bottom left (flip after rounding down) -> clip space.
function locate(e) {
const r = canvas.getBoundingClientRect();
const x = (e.clientX - r.left) * canvas.width / r.width; // scale by canvas size over CSS size
const y = (e.clientY - r.top) * canvas.height / r.height;
return { css: [e.clientX - r.left, e.clientY - r.top],
pixel: [Math.floor(x), canvas.height - 1 - Math.floor(y)],
clip: [2 * x / canvas.width - 1, 1 - 2 * y / canvas.height] };
}
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 uClip, uSize; out vec4 c;
void main() {
vec2 clip = gl_FragCoord.xy / uSize * 2.0 - 1.0; // this fragment in clip space
vec3 color = vec3(0.97, 0.96, 0.93);
vec2 grid = abs(fract(clip * 5.0 + 0.5) - 0.5) / fwidth(clip * 5.0);
color = mix(vec3(0.82, 0.79, 0.72), color, clamp(min(grid.x, grid.y), 0.0, 1.0)); // lines every 0.2
vec2 axis = abs(clip) / fwidth(clip);
color = mix(vec3(0.45), color, clamp(min(axis.x, axis.y) - 0.5, 0.0, 1.0)); // the two axes
vec2 d = abs(gl_FragCoord.xy - (uClip * 0.5 + 0.5) * uSize);
if ((d.x < 1.5 && d.y < 26.0) || (d.y < 1.5 && d.x < 26.0)) color = vec3(0.71, 0.27, 0.18); // the crosshair
if (abs(length(d) - 10.0) < 1.5) color = vec3(0.71, 0.27, 0.18);
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());
gl.uniform2f(gl.getUniformLocation(program, 'uSize'), canvas.width, canvas.height);
let real = null; // a real pointer takes over from the demo
canvas.addEventListener('pointermove', (e) => { real = locate(e); });
canvas.addEventListener('pointerleave', () => { real = null; });
function frame(ms) {
let where = real;
if (!where) { // demo: a virtual pointer tracing a figure eight
const r = canvas.getBoundingClientRect(), t = ms * 0.0006;
where = locate({ clientX: r.left + r.width * (0.5 + 0.35 * Math.sin(t)), clientY: r.top + r.height * (0.5 + 0.3 * Math.sin(2 * t)) });
}
gl.uniform2fv(gl.getUniformLocation(program, 'uClip'), where.clip);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const f = (v, n = 1) => v.map((c) => c.toFixed(n)).join(', ');
document.getElementById('out').textContent =
`CSS pixels (from top left): ${f(where.css)}\n` +
`buffer pixels (from bottom left): ${where.pixel.join(', ')} (canvas ${canvas.width} x ${canvas.height}, shown at ${canvas.clientWidth} x ${canvas.clientHeight})\n` +
`clip space (-1 to 1): ${f(where.clip, 3)}`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>