El canvas de color oculto y su precisión real
Repintar la escena en un búfer paralelo con un color por objeto y leer un píxel: la técnica que responde en tiempo constante, con los cinco ajustes que la hacen exacta.
Si el motor ya sabe qué objeto pintó cada píxel mientras dibujaba, el problema del hit testing se resuelve conservando esa información. La técnica consiste en dibujar la escena una segunda vez en un búfer que nadie ve, dando a cada objeto un color plano que es su identificador, y leer el píxel bajo el puntero. La respuesta llega en tiempo constante con diez objetos y con doscientos mil, y el precio es un búfer entero de memoria y una lectura del canvas, que es la operación más cara de la API.
- Codificar identificadores en color y descodificarlos desde un píxel leído.
- Aplicar los cinco ajustes sin los cuales la técnica devuelve resultados erróneos.
- Amortizar la lectura con un búfer sucio y una copia de píxeles reutilizada.
- Decidir entre esta técnica y la consulta geométrica con un criterio medible.
La idea y el mapa de identificadores
Dos canvas con la misma geometría. En el visible dibujas normal; en el oculto dibujas exactamente las mismas formas, en las mismas posiciones, con un color plano por objeto. El color no es decorativo: es el número del objeto escrito en los tres canales.
// Un identificador entero de 1 a 16777215 codificado en RGB
function colorDeId(id) {
return `rgb(${(id >> 16) & 255} ${(id >> 8) & 255} ${id & 255})`;
}
function idDeColor(r, g, b) {
return (r << 16) | (g << 8) | b;
}
El identificador empieza en uno, nunca en cero, porque el negro puro es lo que vas a leer donde no hay nada. Con veinticuatro bits caben más de dieciséis millones de objetos, que sobra para cualquier escena imaginable.
El resto es simetría: la función que dibuja la escena recibe un parámetro que decide si pinta bonito o pinta identificadores.
function dibujarEscena(ctx, escena, modoSeleccion) {
for (const o of escena.objetos) {
ctx.save();
ctx.translate(o.x, o.y);
ctx.rotate(o.angulo);
ctx.fillStyle = modoSeleccion ? colorDeId(o.id) : o.color;
ctx.fill(o.ruta, o.regla);
if (!modoSeleccion && o.borde) {
ctx.strokeStyle = o.borde;
ctx.lineWidth = 2;
ctx.stroke(o.ruta);
}
ctx.restore();
}
}
Que sea la misma función no es elegancia: es la única forma de garantizar que las dos versiones no se desincronicen. El día que alguien añada un objeto y solo lo dibuje en el visible, ese objeto dejará de ser seleccionable y nadie sabrá por qué.
Los cinco ajustes que hacen que sea exacta
La técnica es trivial de entender y sorprendentemente fácil de implementar mal. Cinco decisiones separan una versión que funciona de una que falla una vez de cada veinte.
Uno: el contexto del búfer oculto va sin canal alfa. Un canvas con transparencia guarda el color premultiplicado por el alfa, y ese ida y vuelta redondea los valores: escribes 137 y lees 136. Un solo bit de diferencia convierte tu identificador en otro identificador válido y el resultado es que se selecciona un objeto que está en otra parte de la pantalla.
const oculto = new OffscreenCanvas(1, 1);
const ctxOculto = oculto.getContext('2d', { alpha: false, willReadFrequently: true });
Dos: willReadFrequently es obligatorio aquí. Le dice al navegador que mantenga este canvas en memoria de sistema en lugar de en la GPU, lo que hace la lectura barata a cambio de dibujar algo más lento. Es exactamente el reparto que quieres, y el porqué está en el coste real de leer del canvas.
Tres: nada de efectos en el modo de selección. Ni globalAlpha distinto de uno, ni sombras, ni modos de fusión, ni gradientes, ni patrones, ni filtros. Todo eso mezcla colores y produce valores que no corresponden a ningún objeto. Si tu escena usa transparencia para el aspecto, en el búfer de selección se dibuja opaca.
Cuatro: el suavizado de bordes no se puede apagar, y hay que convivir con él. El canvas 2D no ofrece ninguna forma de desactivar el antialiasing de las rutas —imageSmoothingEnabled afecta al escalado de imágenes, no a esto—, así que los píxeles del borde de cada figura contienen una mezcla entre su color y el de detrás. Esa mezcla es un identificador falso.
La defensa correcta no es intentar evitarlo sino validar lo leído: solo se acepta un identificador que exista en el mapa de objetos.
const porId = new Map(escena.objetos.map(o => [o.id, o]));
function objetoEnPixel(datos) {
if (datos[3] === 0) return null; // sin alfa no hay nada, por si acaso
const id = idDeColor(datos[0], datos[1], datos[2]);
return porId.get(id) ?? null; // un color mezclado no esta en el mapa
}
Con esa comprobación, el peor caso deja de ser “selecciona el objeto equivocado” y pasa a ser “no selecciona nada”, que es un fallo tolerable de un píxel de ancho. Si aun así molesta, se muestrea una vecindad pequeña y gana el identificador válido más frecuente:
function objetoCerca(ctxOculto, x, y, radio = 2) {
const lado = radio * 2 + 1;
const d = ctxOculto.getImageData(x - radio, y - radio, lado, lado).data;
const votos = new Map();
for (let i = 0; i < d.length; i += 4) {
const o = objetoEnPixel(d.subarray(i, i + 4));
if (o) votos.set(o, (votos.get(o) ?? 0) + 1);
}
let mejor = null, max = 0;
for (const [o, n] of votos) if (n > max) { max = n; mejor = o; }
return mejor;
}
Ese muestreo da además la tolerancia que la técnica no tiene por sí misma: con un radio de diez píxeles, una línea fina se vuelve tocable.
Cinco: el búfer oculto y el visible tienen que tener el mismo tamaño y la misma matriz. Si dibujas el visible con la matriz de densidad, el oculto también. Se puede dibujar el oculto a resolución uno para ahorrar memoria —y es una optimización legítima en escenas enormes— pero entonces hay que dividir las coordenadas de consulta por la densidad y asumir que la precisión baja al tamaño de un píxel físico.
Hay una asimetría incómoda en el canvas de color que no aparece en ningún tutorial y que decide si la técnica sirve para tu caso. El interior de una figura es perfecto: el color es plano, el identificador es exacto, no hay ambigüedad posible. El borde es una franja de aproximadamente un píxel donde el resultado es basura, porque el antialiasing mezcló el color del objeto con el del fondo o con el del objeto de detrás, y el navegador no ofrece manera de desactivarlo. Eso importa mucho más de lo que parece por una razón de comportamiento humano: la gente no pincha en el centro de las cosas, pincha cerca del borde, sobre todo cuando los objetos son pequeños o están juntos. Con figuras de cien píxeles el borde es el uno por ciento del área y nadie lo nota; con una rejilla de celdas de ocho píxeles el borde es la cuarta parte de todo, y la aplicación se siente rota. Hay tres consecuencias prácticas. La primera: si tu escena son objetos pequeños y adyacentes, el canvas de color es la técnica equivocada, y no hay ajuste que lo arregle. La segunda: cuando lo uses, dibuja en el búfer de selección las figuras ligeramente más gruesas que en el visible —un trazo adicional del mismo color con dos o tres píxeles de grosor por encima del relleno— para que la franja mezclada caiga fuera de la figura real en lugar de dentro. Es un truco viejo de los motores de selección y funciona muy bien; el efecto secundario es que los objetos se solapan un poco en el búfer, y gana el que se dibuja el último, que es el que está delante de todas formas. La tercera: no compares nunca los canales con una tolerancia numérica del tipo “si difiere en menos de dos, es el mismo objeto”. Parece razonable y es un error grave, porque los identificadores contiguos difieren exactamente en uno; un color mezclado se parece numéricamente a un objeto que no tiene nada que ver con él. La comparación tiene que ser exacta y la validación, contra el mapa de objetos reales. Ese es también el motivo para no repartir los identificadores de uno en uno cuando puedes permitírtelo: si los espacias, por ejemplo multiplicando por dieciséis, una mezcla tiene muchas menos probabilidades de caer sobre un identificador válido, y el fallo silencioso se vuelve improbable.
El coste de leer y cómo amortizarlo
Cada getImageData es una barrera de sincronización. Con willReadFrequently el canvas vive en memoria de sistema y la lectura es una copia, no una espera a la GPU, pero sigue teniendo un coste fijo por llamada que no depende del tamaño: leer un píxel y leer cien cuesta casi lo mismo.
De ahí salen las dos optimizaciones que hacen la técnica viable en producción.
El búfer de selección solo se redibuja cuando la escena cambia, no en cada consulta. Mover el ratón sobre una escena estática no debe repintar nada.
La lectura se hace una vez por fotograma, no una por evento, exactamente igual que en la consulta geométrica.
class SelectorPorColor {
constructor(ancho, alto) {
this.lienzo = new OffscreenCanvas(ancho, alto);
this.ctx = this.lienzo.getContext('2d', { alpha: false, willReadFrequently: true });
this.sucio = true;
this.porId = new Map();
}
redimensionar(ancho, alto) {
if (this.lienzo.width === ancho && this.lienzo.height === alto) return;
this.lienzo.width = ancho;
this.lienzo.height = alto;
this.sucio = true;
}
invalidar() { this.sucio = true; }
actualizar(escena, matriz) {
if (!this.sucio) return;
this.porId = new Map(escena.objetos.map(o => [o.id, o]));
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.fillStyle = '#000'; // el fondo es el id cero
this.ctx.fillRect(0, 0, this.lienzo.width, this.lienzo.height);
this.ctx.setTransform(matriz);
dibujarEscena(this.ctx, escena, true);
this.sucio = false;
}
consultar(x, y) {
if (x < 0 || y < 0 || x >= this.lienzo.width || y >= this.lienzo.height) return null;
const d = this.ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
const id = (d[0] << 16) | (d[1] << 8) | d[2];
return this.porId.get(id) ?? null;
}
}
El uso encaja en el bucle igual que la otra técnica, con la diferencia de que la actualización del búfer va atada a los cambios de la escena:
const selector = new SelectorPorColor(canvas.width, canvas.height);
function marco() {
if (escena.cambio) { selector.invalidar(); escena.cambio = false; }
selector.actualizar(escena, ctx.getTransform());
if (pendiente) {
const encontrado = selector.consultar(pendiente.x, pendiente.y);
if (encontrado !== resaltado) { resaltado = encontrado; sucia = true; }
pendiente = null;
}
if (sucia) { dibujar(); sucia = false; }
requestAnimationFrame(marco);
}
Y ahí aparece el límite de la técnica: en una escena animada, el búfer de selección está sucio en todos los fotogramas, con lo que dibujas la escena dos veces por fotograma para siempre. Duplicar el coste de dibujo para tener hit testing es un mal negocio salvo que la alternativa sea peor.
Cuándo gana y cuándo no
| Situación | Técnica adecuada |
|---|---|
| Cientos de objetos con rutas, escena mayormente estática | Consulta geométrica con caja envolvente |
| Decenas de miles de objetos, escena estática entre interacciones | Canvas de color |
| Objetos dibujados con imágenes con transparencia | Canvas de color |
| Escena animada continuamente | Consulta geométrica, o índice espacial |
| Formas resultado de recortes o composición compleja | Canvas de color |
| Hace falta tolerancia grande para dedo o lápiz | Consulta geométrica con grosor prestado |
| Memoria muy justa, móviles de gama baja | Consulta geométrica |
El resumen honesto es que el canvas de color es una técnica de nicho que resuelve muy bien dos problemas —muchísimos objetos y formas imposibles de describir con una ruta— y que fuera de ellos cuesta más de lo que da. La consulta geométrica cubre la mayoría de las aplicaciones reales, y conviene empezar siempre por ella.
Implementa las dos técnicas sobre la misma escena con un parámetro para el número de objetos. Mide el tiempo de una consulta con cien, mil y cincuenta mil objetos, y también el tiempo total por fotograma. Verás dos curvas que se cruzan; el punto de cruce en tu hardware es el número a partir del cual la técnica del color deja de ser un capricho.