HTML superpuesto: CSS2DRenderer y CSS3DRenderer
Los dos renderers que colocan elementos del DOM sobre la escena, qué transformación aplica cada uno, y el problema de la oclusión que ninguno resuelve.
Las tres técnicas anteriores convierten el texto en píxeles dentro del canvas de WebGL. Esta hace lo contrario: deja el texto donde estaba, en el DOM, y solo lo mueve para que parezca que está en la escena. Es la única que produce texto seleccionable, buscable, traducible y accesible, y la única que hereda de golpe todo el CSS que ya sabes. También es la única que WebGL no puede ocultar detrás de una pared.
- Montar la capa DOM superpuesta con la geometría y los eventos correctos.
- Distinguir qué transformación aplica
CSS2DRenderery cuálCSS3DRenderer. - Implementar oclusión con raycasting, que ninguno de los dos hace por ti.
- Reconocer los costes ocultos y cuándo la técnica deja de escalar.
Dos renderers, dos transformaciones distintas
Ambos son clases independientes que no tocan WebGL. Tienen setSize y render( scene, camera ), recorren el grafo buscando objetos de su tipo y les asignan un transform de CSS. Nada más. Lo que cambia es qué transform.
CSS2DRenderer proyecta la posición mundial del objeto a coordenadas normalizadas, las convierte a píxeles y escribe una traslación pura:
element.style.transform =
'translate(' + ( -100 * object.center.x ) + '%,' + ( -100 * object.center.y ) + '%)' +
'translate(' + ( x * anchoMedio + anchoMedio ) + 'px,' + ( -y * altoMedio + altoMedio ) + 'px)';
Solo traslación. La etiqueta no rota, no escala con la distancia y no se deforma. Es una anotación pegada a un punto, y esa restricción es exactamente lo que la hace útil: un tooltip legible en cualquier posición de la cámara.
CSS3DRenderer compone la matriz completa del objeto en el espacio de la cámara y la escribe como matrix3d(...), además de fijar la perspective del contenedor a partir del campo de visión de la cámara. El elemento vive en 3D de verdad: rota, escala con la distancia y se ve en escorzo. Con eso puedes meter un iframe, un vídeo o un formulario en el espacio de la escena.
El montaje correcto
La parte que rompe siempre es la geometría del contenedor y los eventos de puntero.
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
const rendererEtiquetas = new CSS2DRenderer();
rendererEtiquetas.setSize( window.innerWidth, window.innerHeight );
const dom = rendererEtiquetas.domElement;
dom.style.position = 'absolute';
dom.style.top = '0';
dom.style.left = '0';
dom.style.pointerEvents = 'none'; // sin esto, la capa se traga los eventos del canvas
document.body.appendChild( dom );
// Cada etiqueta que deba ser interactiva recupera los eventos por su cuenta
const div = document.createElement( 'div' );
div.className = 'etiqueta-3d';
div.textContent = 'Turbina';
div.style.pointerEvents = 'auto';
div.addEventListener( 'click', () => console.log( 'clic en la turbina' ) );
const etiqueta = new CSS2DObject( div );
etiqueta.center.set( 0.5, 1 ); // el punto de anclaje es el borde inferior
etiqueta.position.set( 0, 0.9, 0 );
turbina.add( etiqueta );
function tick() {
controls.update();
renderer.render( scene, camera );
rendererEtiquetas.render( scene, camera ); // segunda pasada, misma escena
requestAnimationFrame( tick );
}
tick();
window.addEventListener( 'resize', () => {
renderer.setSize( window.innerWidth, window.innerHeight );
rendererEtiquetas.setSize( window.innerWidth, window.innerHeight );
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
} );
Tres detalles que ahorran una tarde. El contenedor lleva overflow: hidden de fábrica, así que las etiquetas que caen fuera no producen barras de scroll. CSS2DObject se elimina del DOM automáticamente cuando lo quitas del grafo, mediante un listener del evento removed, así que objeto.remove( etiqueta ) limpia bien. Y sortObjects, que vale true por defecto, asigna un z-index a cada etiqueta según renderOrder y después según distancia a la cámara, de modo que las cercanas tapan a las lejanas.
La oclusión, que hay que hacer a mano
CSS2DRenderer decide la visibilidad de una etiqueta con una única prueba: que su profundidad normalizada esté entre menos uno y uno, más el test de capas. No hay ninguna comprobación contra la geometría de la escena, y no puede haberla: el elemento vive en el DOM, encima del canvas, y el compositor del navegador no sabe nada del búfer de profundidad de WebGL.
El resultado es que las etiquetas de la cara oculta de un objeto se ven a través de él. Para un diagrama explotado eso puede estar bien; para un modelo sólido es inaceptable. La solución es un raycast desde la cámara hacia la etiqueta:
const rayo = new THREE.Raycaster();
const _mundo = new THREE.Vector3();
const _dir = new THREE.Vector3();
function actualizarOclusion( etiqueta, obstaculos, camara ) {
etiqueta.getWorldPosition( _mundo );
_dir.copy( _mundo ).sub( camara.position );
const distancia = _dir.length();
rayo.set( camara.position, _dir.normalize() );
rayo.near = 0;
rayo.far = distancia - 0.02; // no queremos chocar con el propio punto
const tapada = rayo.intersectObjects( obstaculos, true ).length > 0;
etiqueta.element.style.opacity = tapada ? '0' : '1';
etiqueta.element.style.pointerEvents = tapada ? 'none' : 'auto';
}
Nota el pointerEvents junto a la opacidad: un elemento con opacidad cero sigue capturando clics, y una etiqueta invisible que intercepta el ratón es un bug difícil de encontrar.
El coste de esto es un raycast por etiqueta y por cuadro, que con muchos obstáculos no es barato. Dos mitigaciones habituales: pasar solo la lista de mallas grandes en vez de scene.children, y actualizar la oclusión cada tres o cuatro cuadros en vez de cada uno, aprovechando que la transición de opacidad se puede suavizar con CSS.
Costes y límites
El coste por etiqueta es una escritura de style.transform, que el navegador resuelve en el compositor sin recalcular disposición. Es barato hasta cierto punto: con unas pocas decenas de etiquetas ni se nota, con varios cientos empiezas a ver tiempo en “Recalculate Style” en el panel de rendimiento.
Y hay un coste que no es evidente. La prueba de visibilidad solo mira la profundidad, no las coordenadas laterales. Una etiqueta que ha quedado a la izquierda del viewport sigue teniendo display visible y sigue recibiendo su escritura de transform en cada cuadro; simplemente el overflow: hidden del contenedor impide que la veas. En una escena grande con etiquetas repartidas por todo el mundo, la mayoría del trabajo se hace sobre elementos que nadie ve. Si tienes muchas, añade tu propio filtro lateral:
const _ndc = new THREE.Vector3();
function fueraDePantalla( objeto, camara, margen = 1.2 ) {
objeto.getWorldPosition( _ndc ).project( camara );
return Math.abs( _ndc.x ) > margen || Math.abs( _ndc.y ) > margen;
}
// y despues: etiqueta.visible = ! fueraDePantalla( etiqueta, camera );
CSS3DRenderer tiene un límite adicional y más duro: sus elementos viven en una capa de compositor propia, con lo cual nunca son ocluidos por la geometría de WebGL ni participan del post-procesado. Si aplicas bloom o profundidad de campo, el HTML se queda perfectamente nítido flotando encima de una escena difuminada. Para integrarlo hay un truco conocido —dibujar una malla negra con colorWrite: false en el mismo sitio del elemento HTML, con el canvas de WebGL encima y en modo mezcla— pero es frágil y no se lleva bien con el post-procesado.
CSS2DRenderer escribe posiciones en píxeles con decimales, y ahí aparece un artefacto que casi nadie sabe diagnosticar: un elemento colocado en una posición fraccionaria se compone con filtrado bilineal, y el texto sale ligeramente borroso comparado con el mismo texto en una posición entera. Es la misma diferencia que ves al arrastrar un elemento con transform: translate(10.5px, 0) frente a translate(10px, 0). En una escena estática, con la cámara quieta, la etiqueta se queda en una posición fraccionaria arbitraria y su texto se ve peor que el resto de la página sin ninguna razón aparente. Hay dos remedios y uno es peor de lo que parece. El malo es redondear la posición, Math.round, que devuelve la nitidez pero produce un salto visible de un píxel cuando la cámara se mueve despacio. El bueno es aceptar el subpíxel durante el movimiento y forzar el redondeo solo cuando la cámara está en reposo, que es exactamente cuando el ojo se detiene a leer. Detectar el reposo es trivial si usas OrbitControls con damping: cuando el delta de la cámara entre cuadros baja de un umbral, aplica una clase CSS que active transform: translateZ(0) y reescribe la posición redondeada. Es un detalle de tres líneas que separa una interfaz que se ve bien de una que se ve profesional.