Saltar al contenido

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.

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.

JS
// Un identificador entero de 1 a 16777215 codificado en RGBfunction 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.

JS
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.

JS
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.

JS
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:

JS
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.

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.

JS
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:

JS
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.