Una llave del Mundial en three.js: tarjetas de canvas, física de pelota y analytics propio
Durante el Mundial 2026 mantuve un proyecto personal: mundial2026.catcom.com.ar, la llave eliminatoria completa en 3D interactivo — resultados, goleadores, penales, estadios — actualizada partido a partido hasta la final. Se puede volar por el cuadro, abrir cada cruce, resaltar el camino de un equipo y tirarle pelotazos a la copa hasta voltearla.
Stack: three.js vanilla. Sin framework, sin bundler, sin build del lado del cliente. Un index.html, módulos ES nativos con importmap, y un VPS con nginx sirviendo archivos estáticos. El backend de analytics es Python puro (stdlib) + SQLite. Estas son las partes que me parecieron más interesantes de contar.
1. Las tarjetas son canvas, no HTML
Cada partido es un BoxGeometry cuya cara frontal lleva una textura dibujada con Canvas 2D: ronda, banderas, marcador, goleadores con minuto, sede. ¿Por qué canvas y no CSS3D/HTML superpuesto? Texto nítido a cualquier zoom (dibujo a 2× y dejo el anisotropy al máximo), un solo draw call por tarjeta, y atenuar una tarjeta es cambiarle el material.color — no hay DOM que sincronizar con la escena.
Cuando cambia un resultado, regenero la textura de esa tarjeta y listo: los datos viven en un solo archivo (data.js) y la escena entera se reconstruye desde ahí.
2. Conectores de Bézier y una trampa de escala
Los tubos que unen las tarjetas son TubeGeometry sobre curvas de Bézier cúbicas, enganchados borde a borde (no centro a centro). El detalle traicionero: las tarjetas nacen con scale = 0.0001 (animación de aparición), así que localToWorld en ese momento colapsa cualquier punto del borde al centro. La solución fue calcular el borde analíticamente, ignorando la escala transitoria:
// Punto del borde a escala final: position + quaternion·offset
function edgeAt(mesh, localX) {
return new THREE.Vector3(localX, 0, 0)
.applyQuaternion(mesh.quaternion)
.add(mesh.position);
}
Los conectores al partido por el tercer puesto van en bronce (representan a los perdedores de las semis, no a los ganadores) — un color cuenta una regla del torneo.
3. La pelota: puntería balística y colisiones OBB
Clic derecho (o el botón ⚽ en móvil) dispara una pelota. La puntería es balística de verdad: si el rayo del click toca una tarjeta, resuelvo la velocidad inicial para que el proyectil pase por ese punto a pesar de la gravedad:
const flight = Math.max(0.35, origin.distanceTo(target) / 55);
const vel = target.clone().sub(origin).divideScalar(flight);
vel.y += 0.5 * GRAVITY * flight; // compensa la caída
Las colisiones contra tarjetas son caja orientada (OBB): paso la pelota a espacio local de la tarjeta, clamp a los semiejes, y reflejo la velocidad sobre la normal con restitución ~0.7. Con descarte previo por distancia, 8 pelotas × 33 tarjetas por frame ni se siente. El golpe suena (Web Audio sintetizado, cero archivos), vibra en Android, y si le pegás a la copa… se cae dando tumbos y vuelve sola a los 6 segundos.
4. Analytics propio: sin cookies, sin dependencias
No quise Google Analytics. El sitio manda eventos anónimos por sendBeacon a un servicio de ~600 líneas de Python stdlib + SQLite detrás de nginx: pageviews, partidos abiertos, pelotazos, copa derribada, clicks salientes, campañas (?src=). Sin cookies, respetando DNT y GPC, con visitantes únicos por hash irreversible de IP+día+salt (la IP nunca se guarda) y retención de 90 días.
El GeoIP también es artesanal: el dataset gratuito de DB-IP convertido a un binario empaquetado con búsqueda binaria byte a byte (los rangos en big-endian de ancho fijo se comparan como bytes). 14 MB, cero dependencias, cero APIs externas.
5. La puesta en escena
El sistema visual es semántico antes que decorativo. Tres colores cuentan el estado del torneo: dorado = se juega hoy, celeste = ya jugado, punteado tenue = por jugarse. Y hay un cuarto con historia propia: los conectores hacia el partido por el tercer puesto van en bronce, porque no llevan ganadores — llevan a los perdedores de las semis hacia la medalla de bronce. Un color contando una regla del torneo.
Los estados además están doblemente codificados: color y trazo (resplandor sólido grueso vs. línea punteada fina). Si no distinguís el celeste del dorado, el borde te lo dice igual — accesibilidad para daltónicos sin que parezca una concesión.
Arriba de eso, la puesta: ACES filmic tone mapping y un entorno PMREM para que el oro del trofeo refleje como metal y no como plástico amarillo; niebla exponencial, 700 partículas aditivas, una grilla de piso y un halo radial que dan profundidad casi gratis. El HUD es "glass" (backdrop-filter sobre el fondo espacial), tipografía Kanit, y las tarjetas se dibujan a 2× para que el texto aguante cualquier zoom.
La cámara tiene coreografía: al abrir un partido vuela con easing cúbico (corriendo la tarjeta del centro para que el panel de detalle no la tape), las tarjetas nacen escalonadas con un pop de easeOutBack, y la auto-rotación no da vueltas completas: oscila como péndulo entre ±70° y rebota, para que nunca veas el reverso vacío de las tarjetas.
6. Performance, privacidad y estándares
Presupuesto de GPU. Un draw call por tarjeta (la textura canvas es el truco), pixelRatio cappeado a 2, anisotropy solo donde importa. El bucle de render es adaptativo: full rate mientras interactuás, ~20 fps en reposo — el trofeo sigue flotando, pero la GPU y la batería no giran de gusto. La física también es barata a propósito: descarte por distancia al cuadrado antes de la colisión OBB exacta, tope de 8 pelotas simultáneas, y dispose() de texturas al regenerar tarjetas.
Privacidad por diseño. Sin cookies no hay banner de cookies: no hay nada que consentir. Los visitantes únicos salen de un hash irreversible de IP+día+salt que rota a diario — anonimización real, no un seudónimo persistente —, la retención es de 90 días y se honran DNT y GPC (GPC tiene peso legal en California). La ingesta valida todo: whitelist de tipos de evento, payload acotado, rate-limit en nginx. Y el dashboard va con sus headers: CSP default-src 'none', X-Frame-Options: DENY, nosniff, no-referrer.
Accesibilidad. Toda la escena 3D tiene un espejo en HTML semántico (sr-only) para lectores de pantalla; los botones llevan aria-label, el toast anuncia con role="status" aria-live, Escape cierra los paneles. La UI se adapta por capacidad del dispositivo — media queries de hover/pointer, no sniffing de user-agent: el botón de pelotazo aparece donde no hay mouse, sin importar el tamaño de la pantalla. Y si tu sistema pide reducir movimiento (prefers-reduced-motion), la escena obedece: sin vuelos de cámara, sin aparición animada, sin órbita del trofeo ni polvo girando — se quedan el pelotazo (lo iniciás vos) y el countdown (es información, no ornamento).
Web platform pura. ES Modules con importmap, History API, Web Share, Clipboard, Web Audio (los sonidos del minijuego son osciladores sintetizados, cero archivos), Vibration, Pointer Events, sendBeacon + Page Visibility. Cero frameworks: para este proyecto, el estándar alcanzó.
Números
~3.800 líneas en total: 1.700 de JS del cliente y 650 del backend de analytics. Cero dependencias de runtime en el cliente (three.js vendoreado).
Lo que me llevo
- Canvas como textura es lo que va para UI con texto dentro de WebGL.
- Un analytics respetuoso de la privacidad cabe en un archivo y cuenta exactamente lo que necesitás.
Créditos: trofeo 3D de waimus (CC BY-SA 4.0), pelota "FIFA TRIONDA" de About PES (CC BY 4.0), banderas Twemoji (CC BY 4.0), GeoIP de DB-IP (CC BY 4.0).
← Volver a la llave 3D