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.
Una línea de un píxel dibujada en una coordenada entera sale de dos píxeles y gris. Es el segundo problema que tiene todo el mundo con canvas, después del borrón por densidad, y la solución que circula —sumar 0,5— es correcta la mitad de las veces y contraproducente la otra mitad. La regla completa depende del grosor, de la escala del contexto y de la densidad de la pantalla, y una vez entendida es mecánica.
- Explicar por qué una línea de grosor impar en coordenada entera queda repartida entre dos píxeles.
- Deducir la regla general del desplazamiento a partir del grosor y de la escala.
- Aplicar la corrección correctamente en un contexto escalado por la densidad de pantalla.
- Escribir una utilidad que alinee coordenadas a la rejilla del dispositivo.
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 pixeles
ctx.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 2
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineWidth = 1;
// dpr=2 -> grosor real 2 px del buffer -> alinear a entero logico
ctx.beginPath(); ctx.moveTo(30, 10); ctx.lineTo(30, 90); ctx.stroke(); // nitida
// La receta "suma 0.5" aqui hace lo contrario de lo que promete
ctx.beginPath(); ctx.moveTo(70.5, 10); ctx.lineTo(70.5, 90); ctx.stroke(); // borrosa
La 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;
}
Dos hechos sobre lineWidth que casi nadie conoce y que resuelven problemas reales. El primero: lineWidth admite valores fraccionarios y funcionan. En una pantalla de densidad 2, un lineWidth de 0,5 en un contexto escalado produce un trazo de exactamente un píxel del dispositivo, que es la línea más fina físicamente posible en esa pantalla. Es lo que hay que usar para las separaciones y las rejillas si quieres que se vean como las líneas de un píxel del sistema operativo, y es lo que hacen los diseños que se sienten nativos frente a los que se sienten pesados. La fórmula es lineWidth = 1 / escala. El segundo hecho: asignar lineWidth = 0 no dibuja una línea finísima, no dibuja nada, y además la asignación se ignora. La especificación dice que los valores que no sean finitos y positivos se descartan silenciosamente, así que el contexto conserva el grosor anterior. Eso produce un bug memorable: un cálculo que da cero o NaN —una división por cero, un valor sin inicializar— deja el grosor con el valor de la figura anterior, y aparece un elemento con el grosor equivocado sin ningún error. Lo mismo ocurre con globalAlpha fuera del rango de cero a uno y con varias propiedades más: la asignación inválida no lanza, se ignora. Cuando veas un estilo que no cambia por más que lo asignes, comprueba que el valor que le estás dando sea válido; el contexto no te va a avisar.
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.