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