5. Interacción, Raycaster y controles de cámara
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Código: la página en src/05-picking/main.ts, el diapasón que comparte con los scripts en src/05-picking/fretboard.ts, las pruebas de rayos sin navegador en scripts/l05-raycaster.ts, y los ejercicios en scripts/l05-exercises.ts.
Pruebas de impacto, en tres toolkits
Sección titulada «Pruebas de impacto, en tres toolkits»Una escena 3D no sabe dónde está el puntero. WPF hace el trabajo por ti: VisualTreeHelper.HitTest sobre un Viewport3D devuelve un RayMeshGeometry3DHitTestResult con la malla, el punto y el triángulo. JavaFX pone un PickResult en cada evento de ratón. MonoGame te da las piezas: Viewport.Unproject para convertir un punto de la pantalla en dos puntos del mundo, un Ray entre ellos, y Ray.Intersects contra cajas y esferas envolventes, nunca contra triángulos.
three.js está a medio camino. Raycaster convierte una posición del puntero en un rayo que parte de la cámara, y lo prueba contra los triángulos de las mallas; conectar los eventos de puntero es tarea tuya.
| WPF 3D | MonoGame | JavaFX 3D | three.js | |
|---|---|---|---|---|
| Entrada | un punto en el Viewport3D |
un punto en la ventana | un evento de ratón | coordenadas normalizadas de dispositivo, de −1 a 1 |
| Se prueba contra | triángulos de ModelVisual3D |
BoundingBox, BoundingSphere, Plane |
triángulos de Node |
triángulos de Mesh, Points y Line dentro de un umbral, sprites |
| Resultado | el impacto más cercano, o un callback por impacto | una distancia | el nodo, el punto, la cara y la coordenada de textura más cercanos | todos los impactos, ordenados por distancia: objeto, punto, cara, UV, instancia |
| Omitir objetos | IsHitTestVisible |
tu código | mouseTransparent |
capas, o la lista de objetos que pasas |
La escena: un diapasón al que apuntar
Sección titulada «La escena: un diapasón al que apuntar»fretboard.ts vuelve a construir el mástil de la lección 2, más largo: una caja, 12 trastes y 6 cuerdas, todo en un solo Group. Los trastes siguen la regla de una guitarra real, cada uno una raíz duodécima de dos más cerca del puente (línea 16), así que la X de un punto devuelve el traste con un logaritmo, y su Z da la cuerda más cercana (líneas 20-36):
// Dónde pisa un dedo en x: 0 antes del lado de la cejuela del traste 1, n entre el traste n − 1 y el traste nexport function fretAt(x: number): number | null { if (x < NUT_X || x > fretX(FRETS)) return null; return Math.ceil(-12 * Math.log2(1 - (x - NUT_X) / SCALE));}El archivo no importa nada del DOM, así que la página y un script de Node.js lo comparten.
De un evento de puntero a un rayo
Sección titulada «De un evento de puntero a un rayo»Un Raycaster quiere el puntero en coordenadas normalizadas de dispositivo (NDC): −1 en el borde izquierdo del canvas, +1 en el derecho, +1 arriba y −1 abajo, sea cual sea su tamaño en píxeles. La página las calcula a partir del propio rectángulo del canvas (main.ts, líneas 35-44):
// El puntero en coordenadas normalizadas de dispositivo (−1 a 1), a partir del propio rectángulo del canvasconst pointer = new THREE.Vector2();let pointerInside = false;let pointerMoved = false;renderer.domElement.addEventListener('pointermove', (event) => { const rect = renderer.domElement.getBoundingClientRect(); pointer.set(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); pointerInside = true; pointerMoved = true;});La Y se invierte: el DOM cuenta hacia abajo desde arriba, NDC cuenta hacia arriba. setFromCamera hace entonces partir el rayo de la posición de la cámara y lo dirige a través del puntero, desproyectando el punto (x, y, 0.5) con las matrices de la cámara. Con una cámara ortográfica, todos los rayos son paralelos y parten del plano de la cámara.
Después intersectObject prueba el rayo, y el primer impacto da el traste, la cuerda y la nota (líneas 59-73):
function pick(ndc: THREE.Vector2): Pick { raycaster.setFromCamera(ndc, camera); // Recursivo: los hijos del grupo del diapasón son el mástil, los trastes y las cuerdas const [hit] = raycaster.intersectObject(board, true); if (!hit) return null; const fret = fretAt(hit.point.x); const string = stringAt(hit.point.z); return { object: hit.object.name, point: hit.point.toArray().map((n) => Number(n.toFixed(3))), fret, string, note: fret === null ? null : noteName(string, fret), };}Lo que encuentra un rayo, sin navegador
Sección titulada «Lo que encuentra un rayo, sin navegador»Raycaster son simples cálculos en la CPU: sin renderer, sin GPU, sin DOM. scripts/l05-raycaster.ts construye el mismo diapasón y la misma cámara en Node.js, y les lanza rayos:
$ node scripts/l05-raycaster.tspointer (NDC): [ -0.1894, 0.0006 ]ray origin [ -0.2, 2.4, 1.6 ] direction [ -0.122, -0.789, -0.602 ]intersectObject(board, true): neck at 2.584first hit: { point: [ -0.514, 0.36, 0.045 ], faceIndex: 4, uv: [ 0.413, 0.418 ] }fret 5, string 3: C4intersectObject(board, false): (none)over string 1: string 1 at 2.598 | neck at 2.648strings on layer 1: neck at 2.648raycaster.layers.enable(1): string 1 at 2.598 | neck at 2.648far = 2: (none)ray.intersectPlane: [ -0.514, 0.36, 0.045 ]over fret 4, InstancedMesh: frets (instanced) at 2.531, instance 310,000 × intersectObject(board, true): N µs per ray10,000 × ray.intersectPlane: N µs per rayLínea a línea:
- El punto donde un dedo pisa la cuerda 3 en el traste 5 se proyecta en NDC (−0.19, 0.0006): a la izquierda del centro, a media altura. El rayo sale de la cámara en (−0.2, 2.4, 1.6), y alcanza la parte superior del mástil a 2.584 unidades, en (−0.514, 0.36, 0.045): traste 5, cuerda 3, C4.
- Cada impacto lleva el punto, la distancia, el índice del triángulo (
faceIndex) y la coordenada de textura en ese punto (uv), interpolada a partir de los vértices del triángulo. intersectObject(board, false)no encuentra nada: elGroupen sí no tiene geometría, y sinrecursivesus hijos no se prueban.- Sobre la cuerda 1, el rayo alcanza primero la cuerda y luego el mástil que hay detrás: el array contiene todos los impactos a lo largo del rayo, ordenados por distancia, no solo el más cercano.
- Un objeto en la capa 1 se omite:
Raycasterprueba un objeto solo si sus capas comparten alguna con las del raycaster (Raycaster.js, líneas 240-250). No miravisible: una malla invisible sigue recibiendo impactos, algo que el ejercicio 1 convierte en una ventaja. far = 2ignora todo lo que esté a más de 2 unidades, y el mástil está a 2.584.- Contra un plano matemático a la altura del mástil,
ray.intersectPlaneda el mismo punto, sin ninguna malla. - Con los trastes como un único
InstancedMesh, el impacto indica qué instancia: elinstanceId3 es el traste 4. - El coste, en la máquina del autor: unos pocos microsegundos por rayo contra las 19 mallas, unas pocas centésimas de microsegundo contra el plano. Cada malla se prueba primero contra su esfera envolvente, y luego triángulo a triángulo.
check.shsustituye los números por N, ya que cambian en cada ejecución.
Dos detalles del script importan en cualquier código que lance rayos fuera de un bucle de render. Una página renderiza antes de lanzar rayos, y renderizar actualiza las matrices de mundo de los objetos; un script, un test o un manejador de eventos que se ejecuta antes del primer frame debe llamar él mismo a updateMatrixWorld(). Y la cámara, que no está en la escena, también lo necesita: sin camera.updateMatrixWorld(), la primera versión de este script proyectaba el punto pisado a un NDC de −3.6, muy fuera del canvas.
Eventos de puntero reales en un navegador headless
Sección titulada «Eventos de puntero reales en un navegador headless»La sonda de la página pide a scripts/probe.mjs que mueva el ratón de Playwright sobre el canvas, en 5 pasos, hasta la posición en pantalla de la cuerda 3 traste 5, luego de la cuerda 6 traste 8, luego por encima del mástil, y que arrastre 120 píxeles hacia la derecha. Chromium convierte esto en eventos pointermove, pointerdown y pointerup reales. Tras cada acción, la página renderiza dos frames e informa de lo que ha seleccionado:
$ node scripts/probe.mjs 05-picking{ "frame": 4, "render": { "calls": 6, "drawCalls": 20, "triangles": 781 }, "memory": { "geometries": 9, "textures": 3, "programs": 4 }, "canvas": [0, 0, 800, 450], "after": { "string 3, fret 5": { "pick": { "object": "neck", "point": [-0.515, 0.36, 0.046], "fret": 5, "string": 3, "note": "C4" } }, "string 6, fret 8": { "pick": { "object": "neck", "point": [0.286, 0.36, -0.226], "fret": 8, "string": 6, "note": "C3" } }, "beside the neck": { "pick": null }, "orbit 120 px right": { "camera": [-1.791, 2.4, -0.167], "target": [-0.2, 0.3, 0] } }}
Página en vivo: ejecutarla aquí
Abrir a pantalla completa. Pruébalo: marca 240 px sidebar para el diseño del ejercicio 3 y pasa el puntero por las cuerdas; marca damping y arrastra para notar lo que cambia enableDamping.
La captura se toma después del primer movimiento: el marcador naranja está donde está el puntero. El punto seleccionado difiere del del script en el tercer decimal, (−0.515, 0.36, 0.046) en lugar de (−0.514, 0.36, 0.045), porque la página redondea el objetivo a píxeles CSS enteros antes de mover el ratón allí. Hay tres cosas que vale la pena leer:
- 20 draw calls para el mástil, 12 trastes, 6 cuerdas y el pase de salida. La selección no cuesta nada en la GPU.
- Un raycast por frame. La página lanza rayos como mucho una vez por frame, y solo cuando el puntero se ha movido (líneas 75-92). Un ratón puede enviar más eventos
pointermoveque frames muestra la pantalla, y un raycast en el manejador de eventos probaría la escena para posiciones que nadie ve. - La órbita. Un arrastre de 120 píxeles giró la cámara 96° alrededor del objetivo:
OrbitControlsrota 2π × píxeles ÷ la altura del elemento, “yes, height” dice el código fuente (OrbitControls.js, línea 1118), así que 120 ÷ 450 de una vuelta completa. La cámara se mantiene a 1.6 unidades del eje y a la misma altura.
Controles
Sección titulada «Controles»const controls = new OrbitControls(camera, renderer.domElement);controls.target.set(-0.2, 0.3, 0);controls.enableDamping = params.has('damping');controls.update();OrbitControls vive en three/addons/controls/OrbitControls.js, no en el núcleo. Escucha pointerdown en el canvas, luego captura el puntero (setPointerCapture) y escucha pointermove y pointerup en el documento, así que un arrastre que sale del canvas sigue girando (líneas 1553-1564). Fija touch-action: none en el canvas, para que un dedo gire la cámara en lugar de desplazar la página. Con enableDamping, la cámara sigue moviéndose un rato después del arrastre: controls.update() debe entonces ejecutarse en cada frame, y el bucle lo hace. controls.dispose() quita los listeners (líneas 512-538); un componente que crea controles y nunca llama a su dispose deja esos listeners atrás.
Los addons tienen otros controles, con el mismo constructor: MapControls (primero el desplazamiento, como en un mapa), TrackballControls (sin dirección vertical fija), PointerLockControls (primera persona), TransformControls (un gizmo para mover un objeto) y DragControls.
Cuando el canvas no empieza en (0, 0)
Sección titulada «Cuando el canvas no empieza en (0, 0)»Muchos ejemplos en la web calculan las NDC a partir de la ventana: event.clientX / window.innerWidth * 2 - 1. Eso solo es correcto cuando el canvas ocupa toda la ventana. Con ?sidebar, la página pone un panel de 240 píxeles a la izquierda del canvas, y calcula ambas versiones para el mismo evento de puntero (líneas 50-54):
$ node scripts/probe.mjs 05-picking --query sidebar{ "frame": 4, "render": { "calls": 6, "drawCalls": 20, "triangles": 781 }, "memory": { "geometries": 9, "textures": 3, "programs": 4 }, "canvas": [240, 0, 560, 450], "after": { "string 3, fret 5": { "pick": { "object": "neck", "point": [-0.515, 0.36, 0.046], "fret": 5, "string": 3, "note": "C4" }, "pickFromWindow": { "object": "neck", "point": [-0.072, 0.36, 0.046], "fret": 7, "string": 3, "note": "D4" } }, "string 6, fret 8": { "pick": { "object": "neck", "point": [0.286, 0.36, -0.226], "fret": 8, "string": 6, "note": "C3" }, "pickFromWindow": { "object": "neck", "point": [0.511, 0.36, -0.226], "fret": 9, "string": 6, "note": "C#3" } }, "beside the neck": { "pick": null, "pickFromWindow": null }, "orbit 120 px right": { "camera": [-1.791, 2.4, -0.167], "target": [-0.2, 0.3, 0] } }}El canvas mide ahora 560 por 450 píxeles en x = 240. A partir de su propio rectángulo, la página sigue encontrando la cuerda 3 traste 5. A partir de la ventana, el mismo puntero cae dos trastes más allá, en una nota un tono entero más aguda, sin ningún error en ninguna parte. El listener resize de la página tiene la misma trampa: dimensiona el renderer a partir de su contenedor, no de la ventana (líneas 94-103).
En GuitarAlchemist/ga
Sección titulada «En GuitarAlchemist/ga»GA usa Raycaster en 14 archivos. Todos calculan las NDC a partir del rectángulo del canvas, así que el error de la barra lateral de arriba no aparece. Las diferencias están en cuándo lanzan rayos, y en lo que limpian.
- Un listener por cada ejecución del efecto.
ThreeHeadstock.tsxañade su manejador de clic concanvas.addEventListener('click', handleClick)ensetupMouseInteraction(líneas 698-744), llamado desde unuseEffectcuyas dependencias incluyen el modelo de guitarra, el estilo, la afinación, el ancho y la altura (línea 178). La limpieza libera los controles y el renderer, pero nunca quita el listener, y el canvas es el mismo elemento de una ejecución a otra (canvasRef). Tras cada cambio de afinación, un manejador más se ejecuta en cada clic, cada uno con la escena de su propia ejecución. Si las escenas antiguas siguen llamando aonTuningPegClickestá por verificar en el navegador. El manejador además pruebascene.childrenrecursivamente (línea 713): todas las mallas de la pala, cuando solo las clavijas se pueden pulsar. - Un raycast por evento de ratón.
TonalOrbit.tsxselecciona en cadapointermove(líneas 835-850), y construye un nuevoVector2y un nuevo array de mallas cada vez (líneas 799-813).MinimalThreeInstrument.tsxcrea un nuevoRaycasteren su manejador ReactonMouseMove(líneas 664-675). La basura es poca; los raycasts para posiciones que ningún frame muestra son el coste mayor. - El patrón de esta lección, ya en GA.
InteractionHandler.ts, de Prime Radiant, guarda las NDC enmousemove(líneas 64-68), lanza los rayos desde el bucle de render, y quita sus tres listeners endispose(líneas 58-62). Sus nodos llevan una esfera invisible, más grande, para que el rayo la alcance (NodeRenderer.ts, líneas 86-95), la técnica del ejercicio 1. Comprueba el hover en cada frame, incluso cuando el puntero no se ha movido; el flagpointerMovedde la página de esta lección se los ahorraría. - Controles sin
dispose. De los 25 archivos que creanOrbitControls, cuatro nunca los liberan, entre ellosBSP/HarmonicNavigator3D.tsx(línea 189), cuya limpieza solo libera el renderer (líneas 250-260). El canvas se elimina con el componente, y sus listeners con él, pero el listenerkeydownque los controles añaden al documento (línea 505) se queda.
Demo en vivo: una guitarra entera
Sección titulada «Demo en vivo: una guitarra entera»Los mismos dos pasos sobre una guitarra entera, modelada en código en lugar de cargada: el cuerpo y el clavijero son contornos extruidos, y los trastes, los marcadores, los polos de las pastillas, las selletas y las clavijas son meshes instanciados. Un rayo contra una caja invisible sobre el diapasón, y luego su cruce con el plano de las cuerdas, dan un traste a partir de la coordenada x y una cuerda a partir de la coordenada z, con fretAtX y noteAt de la lección 13. Un clic pulsa la cuerda, que vibra en su vertex shader, y toca la nota con un pequeño sintetizador Karplus-Strong sobre la Web Audio API. Su sonda hace clic en la cuerda 3, traste 5, con el ratón de Playwright y lee string 3, fret 5: C4, con 32 draw calls y 47 405 triángulos. El código está en code/threejs/demos/guitar.

Página en vivo: ejecutarla aquí
Abrir a pantalla completa. Pruébalo: haz clic en una cuerda entre dos trastes; el panel rasguea acordes, y ?view=body o ?view=headstock empieza más cerca. La selección no toca los meshes de las cuerdas, así que el problema del ejercicio 1 no aparece aquí: compara los dos enfoques.
Experimento GA
Sección titulada «Experimento GA»El experimento 3 de la lección 14 selecciona una nota entre 10 000: Raycaster tarda medio milisegundo, siete veces menos que una pasada de identificadores en la GPU con su lectura, y los dos se cruzan hacia las 100 000 notas. En WebGL 2, la lectura sola tardó 17 ms.
Puntos clave
Sección titulada «Puntos clave»Raycaster.setFromCamerarecibe coordenadas normalizadas de dispositivo: calcúlalas a partir delgetBoundingClientRect()del canvas, no de la ventana.intersectObject(object, true)devuelve todos los impactos a lo largo del rayo, el más cercano primero, con punto, distancia, cara, UV y, para mallas instanciadas,instanceId.- El raycaster mira las capas, no
visible;far,neary la lista de objetos que pasas limitan el trabajo. - El raycasting son cálculos en la CPU: pruébalo en Node.js, pero actualiza tú mismo las matrices de mundo, incluida la de la cámara.
- Lanza los rayos desde el bucle de frames, una vez, cuando el puntero se haya movido; quita tus listeners y libera los controles cuando el componente desaparezca.
Ejercicios
Sección titulada «Ejercicios»- Las cuerdas son finas: un puntero a 0.012 unidades al lado de la cuerda 1 cae en el mástil. Haz que cada cuerda sea más fácil de alcanzar sin que parezca más gruesa. Compruébalo con
scripts/l05-exercises.ts. - Los puntos de nota dibujados como
Pointsno tienen superficie. ¿Cómo decideRaycasterque un punto recibe un impacto? Apunta 0.04 unidades a la derecha del punto del traste 5, y prueba umbrales de 1, 0.1, 0.05 y 0.02. - Con el diseño
?sidebar, la selección basada en la ventana decía traste 7. Sin ejecutar la página, ¿qué traste diría con una barra lateral de 480 píxeles, y por qué no puedes deducirlo solo a partir de las NDC?
Solución 1
Añade un cilindro invisible y más grueso en la posición de cada cuerda, en el mismo grupo. Raycaster mira las capas, no visible, y el renderer omite los objetos invisibles (líneas 13-31):
const proxyMaterial = new THREE.MeshBasicMaterial();for (const string of strings) { const proxy = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 1, 8).rotateZ(Math.PI / 2), proxyMaterial); proxy.name = `${string.name} (hit area)`; proxy.visible = false; proxy.scale.x = (string.geometry as THREE.CylinderGeometry).parameters.height; proxy.position.copy(string.position); board.add(proxy);}$ node scripts/l05-exercises.ts0.012 beside string 1: neck at 2.654with invisible hit areas: string 1 (hit area) at 2.584 | neck at 2.654...El nombre de la zona de impacto dice qué cuerda representa. El NodeRenderer.ts de GA hace lo mismo con visible: false en el material en lugar de en la malla; Mesh.raycast solo sale antes de tiempo cuando no hay ningún material (Mesh.js, línea 244), así que ambas formas funcionan.
Solución 2
threshold 1: 3 hit(s), nearest to the ray: index 2, first: index 3threshold 0.1: 1 hit(s), nearest to the ray: index 2, first: index 2threshold 0.05: 1 hit(s), nearest to the ray: index 2, first: index 2threshold 0.02: 0 hit(s)Para Points, un impacto es un punto cuya distancia al rayo es menor que raycaster.params.Points.threshold, en unidades del mundo; el valor por defecto es 1, más que el ancho de todo este diapasón. Con 1, cuentan tres de los cuatro puntos, y hits[0] es el más cercano a la cámara a lo largo del rayo, el índice 3 (traste 7), no el más cercano al rayo: cada impacto tiene un distanceToRay por el que ordenar. De 0.1 a 0.05, solo queda el punto del traste 5; con 0.02, el rayo pasa a 0.04 y no alcanza nada. Line tiene el mismo parámetro, params.Line.threshold. Con PointsMaterial.sizeAttenuation, el tamaño de un punto en pantalla cambia con la distancia pero el umbral no: un umbral en píxeles necesita tu propia conversión.
Solución 3
Depende de dónde esté el puntero, no solo de la barra lateral. Las NDC basadas en la ventana son clientX / innerWidth, las correctas son (clientX − left) / width: el error crece con el panel y se reduce hacia el borde derecho de la ventana, donde ambas llegan a 1. Con un panel de 480 píxeles, el canvas mediría 320 píxeles de ancho, y el puntero en la cuerda 3 traste 5 estaría también en otro clientX, así que el traste equivocado no se puede leer del primer resultado. Razonado, no medido: ejecuta la página con un .sidebar más ancho para comprobarlo (por verificar).
Fuentes
Sección titulada «Fuentes»- Documentación de three.js:
Raycaster,Layers,OrbitControls,InstancedMesh,Points - Código fuente de three.js en r186:
Raycaster.js,Mesh.js,OrbitControls.js - MDN: Pointer events,
Element.getBoundingClientRect(),Element.setPointerCapture() - Playwright:
Mouse