De píxeles a coordenadas normalizadas de dispositivo
Cómo se convierte la posición del puntero en el par de números que espera setFromCamera, por qué el eje Y va al revés, y cómo hacerlo bien cuando el canvas no ocupa toda la ventana.
setFromCamera pide un Vector2 con componentes entre menos uno y uno. Esa conversión desde píxeles parece trivial y es la fuente de la mitad de los fallos de picking que se ven en producción: canvas que no ocupa la ventana entera, página con scroll, contenedor con borde, transformaciones CSS, y el eterno signo del eje Y. Merece la pena hacerla una vez bien y no volver a pensar en ella.
- Convertir coordenadas de puntero a NDC de forma correcta para cualquier disposición del canvas.
- Explicar por qué el eje Y se invierte y por qué el device pixel ratio no interviene.
- Usar eventos de puntero unificados en lugar de ratón y táctil por separado.
- Reconstruir el rayo a mano con
unprojectpara entender qué hacesetFromCamera.
El espacio de coordenadas normalizado
Después de la proyección y la división por perspectiva, todo lo visible cae en un cubo. En la convención de WebGL ese cubo va de menos uno a uno en X e Y, con el origen en el centro de la pantalla, X creciendo a la derecha e Y creciendo hacia arriba. El espacio de la página, en cambio, tiene el origen arriba a la izquierda y la Y crece hacia abajo. De ahí sale el signo negativo que todo el mundo copia sin saber por qué.
La conversión correcta parte del rectángulo del canvas, no de la ventana:
const puntero = new THREE.Vector2();
function actualizarPuntero( evento, canvas ) {
const rect = canvas.getBoundingClientRect();
const x = ( evento.clientX - rect.left ) / rect.width;
const y = ( evento.clientY - rect.top ) / rect.height;
puntero.x = x * 2 - 1;
puntero.y = - ( y * 2 - 1 );
}
clientX y clientY son coordenadas relativas al viewport, no al documento, así que el scroll de la página ya está descontado. getBoundingClientRect() devuelve la posición y el tamaño del canvas también en coordenadas de viewport, y además tiene en cuenta transformaciones CSS aplicadas al elemento. Restando y dividiendo obtienes una fracción de cero a uno dentro del canvas, y de ahí a NDC es un escalado y un desplazamiento.
La versión que ves en casi todos los ejemplos oficiales usa window.innerWidth y window.innerHeight directamente:
// Solo valido si el canvas ocupa exactamente la ventana y esta en 0,0.
puntero.x = ( evento.clientX / window.innerWidth ) * 2 - 1;
puntero.y = - ( evento.clientY / window.innerHeight ) * 2 + 1;
Es correcta en las demos, donde el canvas es toda la ventana. En una aplicación real, con el canvas dentro de un panel, con cabecera, o dentro de un contenedor con padding, produce un desplazamiento sistemático del picking que se nota como “el clic va unos píxeles a la izquierda”. Usa siempre la versión con getBoundingClientRect.
Es un error habitual multiplicar por devicePixelRatio en algún punto de la conversión. No hay que hacerlo. rect.width está en píxeles CSS, clientX está en píxeles CSS, y su cociente es una fracción adimensional. El devicePixelRatio afecta a cuántos píxeles físicos tiene el framebuffer —eso lo gestiona renderer.setPixelRatio()— pero no cambia dónde está el ratón dentro del rectángulo. Si tu picking se desvía al cambiar de monitor, casi seguro es que has metido el DPR donde no tocaba.
Eventos de puntero, no de ratón
Los eventos pointerdown, pointermove y pointerup unifican ratón, táctil y lápiz en una única API, exponen clientX y clientY igual que los de ratón, y además traen pointerId, pointerType, pressure y isPrimary. No hay razón para escribir mousemove y touchmove por separado en 2026.
const canvas = renderer.domElement;
canvas.addEventListener( 'pointermove', ( e ) => {
actualizarPuntero( e, canvas );
} );
canvas.addEventListener( 'pointerdown', ( e ) => {
if ( ! e.isPrimary ) return; // ignora dedos secundarios en multitactil
actualizarPuntero( e, canvas );
seleccionar();
} );
Un detalle que evita bugs de arrastre: setPointerCapture hace que el elemento siga recibiendo eventos aunque el puntero salga de él, lo que es exactamente lo que quieres cuando el usuario arrastra un objeto y se pasa del borde del canvas.
canvas.addEventListener( 'pointerdown', ( e ) => {
canvas.setPointerCapture( e.pointerId );
} );
canvas.addEventListener( 'pointerup', ( e ) => {
canvas.releasePointerCapture( e.pointerId );
} );
Y una regla que ahorra frames: no lances el raycast dentro del manejador de pointermove. Los eventos de puntero pueden llegar a más de doscientos por segundo en un ratón de alta frecuencia, y hacer un raycast en cada uno significa hacer tres o cuatro raycasts por frame que se van a descartar. Guarda las coordenadas en el manejador y haz el raycast una vez en el bucle de render.
let punteroSucio = false;
canvas.addEventListener( 'pointermove', ( e ) => {
actualizarPuntero( e, canvas );
punteroSucio = true;
} );
function animate() {
if ( punteroSucio ) {
raycaster.setFromCamera( puntero, camera );
resolverHover();
punteroSucio = false;
}
renderer.render( scene, camera );
}
Qué hace setFromCamera por dentro
Merece la pena ver el código porque explica dos comportamientos que sorprenden.
// Camara en perspectiva.
raycaster.ray.origin.setFromMatrixPosition( camera.matrixWorld );
raycaster.ray.direction
.set( coords.x, coords.y, 0.5 )
.unproject( camera )
.sub( raycaster.ray.origin )
.normalize();
Con perspectiva, el origen del rayo es la posición de la cámara, y la dirección se obtiene desproyectando un punto a mitad de profundidad y restando el origen. Ese 0.5 en Z no tiene nada de mágico: cualquier valor entre menos uno y uno da la misma dirección tras normalizar, porque todos los puntos desproyectados de una misma coordenada de pantalla están en la misma recta que sale del ojo. Se elige un valor central por estabilidad numérica.
// Camara ortografica.
raycaster.ray.origin
.set( coords.x, coords.y, ( camera.near + camera.far ) / ( camera.near - camera.far ) )
.unproject( camera );
raycaster.ray.direction.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
Con ortográfica no hay punto de fuga: todos los rayos son paralelos. Lo que cambia con la coordenada de pantalla es el origen, no la dirección, que es siempre el eje Z negativo de la cámara. Por eso en una escena ortográfica el origen del rayo se sitúa en el plano de la cámara, y por eso raycaster.near se comporta de forma distinta: cuenta desde ese plano, que puede estar detrás de la geometría.
Si la cámara no es de ninguno de los dos tipos, setFromCamera registra un error y no toca el rayo. Ese silencio parcial es fácil de pasar por alto: si usas una cámara personalizada, tendrás que construir el rayo tú.
Aquí hay una asimetría entre lo que dibuja el renderer y lo que interseca el raycaster que produce fallos desconcertantes en cuanto la escena deja de ser trivial. setFromCamera construye el rayo a partir de camera.matrixWorld y camera.projectionMatrix, exactamente igual que el render. Hasta ahí todo coherente. La diferencia está en lo que modifica la geometría después. Si un objeto desplaza sus vértices en el vertex shader —un terreno con displacement map, una bandera ondeando, hierba animada, un positionNode en TSL, cualquier cosa que hayas visto en los niveles de shaders— el raycaster interseca la geometría original, sin deformar, porque la deformación solo existe dentro de la GPU y la CPU nunca la ve. El clic acierta donde estaba el vértice antes de animarse, no donde se dibuja. Lo mismo ocurre, aunque menos obvio, con el skinning: SkinnedMesh sí implementa un raycast que aplica la pose actual, pero el coste es notable, y con morph targets el getVertexPosition de Mesh los tiene en cuenta pero recalculando la posición vértice a vértice. Y el caso que más tiempo hace perder: un objeto invisible sigue siendo intersecable. object.visible = false afecta al render y no al raycast, porque el recorrido de Raycaster solo consulta layers, nunca visible. Un menú oculto detrás de la escena, un objeto de depuración desactivado, un nivel de LOD que no está en pantalla: todos siguen robando clics. Si quieres que algo desaparezca de verdad para el picking, sácalo de la lista que pasas a intersectObjects o quítale la capa. Es la diferencia entre “no se dibuja” y “no existe”, y Raycaster solo entiende la segunda.
- Mete el canvas dentro de un contenedor con margen, borde y una cabecera encima.
- Implementa el picking con
window.innerWidthy comprueba cuántos píxeles se desvía. - Cámbialo a
getBoundingClientRecty verifica que el error desaparece. - Aplica un
transform: scale(0.8)por CSS al contenedor y comprueba que la versión correcta sigue funcionando. - Sustituye el raycast en
pointermovepor el patrón de bandera y mide cuántos raycasts te ahorras con un ratón de 240 Hz.