lineWidth y el problema del medio píxel
Resolver de una vez por qué las líneas finas salen grises, cuándo hay que desplazar medio píxel y por qué esa regla se invierte en pantallas de alta densidad.
De dónde sale el medio píxel
El trazo se centra en la ruta. Una línea vertical trazada en x = 10 con lineWidth = 1 ocupa el intervalo de 9,5 a 10,5 en el eje X.
Ahora recuerda la convención de coordenadas: el píxel de índice 9 ocupa el intervalo de 9 a 10, y el de índice 10 ocupa el de 10 a 11. El trazo cubre la mitad de cada uno. El rasterizador calcula la cobertura y pinta ambos al cincuenta por ciento.
Resultado: dos columnas de color a media intensidad en lugar de una a intensidad plena. Se ve más ancha y más apagada.
Si en cambio trazas en x = 10.5, el trazo ocupa de 10 a 11, que es exactamente el píxel 10. Cobertura completa, un solo píxel, color pleno.
ctx.strokeStyle = '#cdd6f4';ctx.lineWidth = 1;ctx.beginPath(); ctx.moveTo(30, 10); ctx.lineTo(30, 90);ctx.stroke(); // dos columnas al 50%ctx.beginPath(); ctx.moveTo(70.5, 10); ctx.lineTo(70.5, 90);ctx.stroke(); // una columna al 100%La regla general
El desplazamiento no siempre es medio píxel: depende de la paridad del grosor.
Con grosor impar —1, 3, 5— el trazo se reparte una cantidad entera de píxeles a cada lado más medio píxel de sobra, así que hay que centrarlo en un centro de píxel, es decir, en una coordenada terminada en 0,5.
Con grosor par —2, 4, 6— el trazo se reparte exactamente el mismo número de píxeles a cada lado, así que hay que centrarlo en un borde de píxel, es decir, en una coordenada entera.
function alinear(coordenada, grosor) { return grosor % 2 === 1 ? Math.round(coordenada) + 0.5 // impar: centro de pixel : Math.round(coordenada); // par: borde de pixel}Y hay una regla más que la gente aplica sin saber por qué: fillRect con coordenadas y dimensiones enteras siempre sale nítido, porque el relleno cubre exactamente los píxeles que van del borde inicial al final, sin repartos. Por eso la técnica de dibujar líneas finas con fillRect en lugar de con stroke funciona sin ningún desplazamiento:
// Linea vertical de 1px nitida, sin pensar en medios pixelesctx.fillStyle = '#cdd6f4';ctx.fillRect(30, 10, 1, 80);Para rejillas y ejes, esta variante es más simple y más rápida que trazar, porque evita todo el aparato de cálculo de contorno. La pierdes en cuanto necesitas líneas oblicuas, guiones o extremos redondeados.
El giro: la escala del contexto
Aquí es donde la regla de “suma 0,5” se rompe, y donde casi ningún tutorial llega.
Si el contexto está escalado por la densidad de pantalla —que es lo que debe estar—, tus coordenadas ya no son píxeles del búfer. Con un contexto escalado por 2, una unidad lógica son dos píxeles del búfer.
Un lineWidth = 1 en ese contexto produce un trazo de dos píxeles del búfer. Y dos es par, así que la regla dice que hay que centrarlo en un borde de píxel del búfer, es decir, en una coordenada lógica entera. Sumar 0,5 lo colocaría en el centro de un píxel lógico, que en el búfer es el borde entre los píxeles 1 y 2 de esa unidad, con lo que volvería a repartirse.
Es decir: en una pantalla de densidad 2, sumar 0,5 emborrona en lugar de afilar.
const dpr = window.devicePixelRatio; // supongamos 2ctx.setTransform(dpr, 0, 0, dpr, 0, 0);ctx.lineWidth = 1;// dpr=2 -> grosor real 2 px del buffer -> alinear a entero logicoctx.beginPath(); ctx.moveTo(30, 10); ctx.lineTo(30, 90); ctx.stroke(); // nitida// La receta "suma 0.5" aqui hace lo contrario de lo que prometectx.beginPath(); ctx.moveTo(70.5, 10); ctx.lineTo(70.5, 90); ctx.stroke(); // borrosaLa regla correcta y general trabaja siempre en píxeles del dispositivo:
/** * Alinea una coordenada logica para que el trazo caiga en la rejilla real. * grosorLogico: el lineWidth que vas a usar, en unidades logicas. * escala: el factor por el que esta escalado el contexto. */function alinearReal(coordenadaLogica, grosorLogico, escala) { const grosorReal = grosorLogico * escala; const posReal = coordenadaLogica * escala; const alineadaReal = Math.round(grosorReal) % 2 === 1 ? Math.round(posReal) + 0.5 : Math.round(posReal); return alineadaReal / escala;}Una utilidad completa para rejillas
Con todo lo anterior, esta es la función que dibuja una rejilla nítida en cualquier densidad. Funciona pegada tal cual.
<!doctype html><meta charset="utf-8"><style> body { margin: 0; background: #1e1e2e; } .caja { position: relative; width: 100%; height: 70vh; } .caja > canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }</style><div class="caja"><canvas id="c">Rejilla.</canvas></div><script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); function pintar() { const dpr = window.devicePixelRatio || 1; const r = canvas.getBoundingClientRect(); const w = Math.round(r.width * dpr), h = Math.round(r.height * dpr); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const W = w / dpr, H = h / dpr; ctx.clearRect(0, 0, W, H); // Linea de un pixel FISICO: el grosor mas fino que la pantalla admite const fino = 1 / dpr; // Con grosor de 1 px real (impar) hay que centrar en medio pixel real const ajuste = (0.5) / dpr; ctx.lineWidth = fino; ctx.strokeStyle = '#45475a'; ctx.beginPath(); for (let x = 0; x < W; x += 24) { const px = Math.round(x * dpr) / dpr + ajuste; ctx.moveTo(px, 0); ctx.lineTo(px, H); } for (let y = 0; y < H; y += 24) { const py = Math.round(y * dpr) / dpr + ajuste; ctx.moveTo(0, py); ctx.lineTo(W, py); } ctx.stroke(); // Ejes mas gruesos: 2 px reales (par) -> alinear a entero real ctx.lineWidth = 2 / dpr; ctx.strokeStyle = '#89b4fa'; ctx.beginPath(); const cx = Math.round(W / 2 * dpr) / dpr; const cy = Math.round(H / 2 * dpr) / dpr; ctx.moveTo(cx, 0); ctx.lineTo(cx, H); ctx.moveTo(0, cy); ctx.lineTo(W, cy); ctx.stroke(); } new ResizeObserver(pintar).observe(canvas); addEventListener('resize', pintar); pintar();</script>Observa que toda la aritmética de alineación se hace multiplicando por el ratio, redondeando y dividiendo. Es la única forma de garantizar que la coordenada cae donde toca en la rejilla física, independientemente de la densidad.
Cuándo no molestarse
La alineación importa cuando el elemento es fino y recto. Fuera de ahí, no vale la pena.
Una línea diagonal se rasteriza con antialiasing de todos modos y no hay ninguna posición que la haga nítida. Un trazo grueso tiene bordes suaves que nadie percibe como borrosos. Una curva, lo mismo. Y en pantallas de densidad 3 o superior, la diferencia entre alineado y no alineado empieza a ser imperceptible incluso en líneas finas, porque el error es de un tercio de píxel físico.
La lista corta de lo que sí conviene alinear: rejillas, ejes, separadores, bordes de tabla, marcos de un píxel y cualquier línea horizontal o vertical de grosor menor o igual a dos.