Screen to Clip Space

Converting Screen Coordinates to Clip Space

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.

A pointer position in CSS pixels, drawing-buffer pixels (y up) and clip space, marked on a clip-space gridHTMLLive
<!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>