9. React Three Fiber y drei
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: el componente en src/09-r3f/Fretboard.tsx, y la página que lo monta en src/09-r3f/main.tsx.
Un renderizador para React
Sección titulada «Un renderizador para React»React Three Fiber (R3F) es un renderizador de React, como react-dom, cuyos elementos host son objetos de three.js en lugar de nodos del DOM. <mesh> crea un THREE.Mesh, <boxGeometry args={[1, 2, 3]} /> llama a new THREE.BoxGeometry(1, 2, 3) y la asigna como geometry del padre, y cualquier otra prop se asigna como propiedad: position={[1, 0, 0]} llama a position.set(1, 0, 0), rotation-x={0.5} asigna rotation.x. El reconciliador de React decide qué crear, actualizar y eliminar; R3F lo aplica al grafo de escena, y renderiza la escena en su propio bucle. drei es una biblioteca de componentes listos para usar encima: controles, superposiciones HTML, texto, loaders, helpers.
Si conoces WPF, la idea te resultará familiar: un árbol declarativo, donde una propiedad cambiada actualiza un objeto en lugar de reconstruir la escena. La diferencia está en cómo llegan los cambios. WPF enlaza propiedades a objetos observables; React vuelve a ejecutar la función del componente y compara su salida.
| WPF 3D | three.js | React Three Fiber | |
|---|---|---|---|
| Descripción de la escena | XAML: <ModelVisual3D>, <GeometryModel3D> |
código: new Mesh(), scene.add() |
JSX: <mesh>, <group> |
| Actualizaciones | bindings a INotifyPropertyChanged |
tu código asigna propiedades | un nuevo render; R3F compara las props |
| Argumentos del constructor | ninguno: solo propiedades | new BoxGeometry(1, 2, 3) |
args={[1, 2, 3]}, y un objeto nuevo cuando cambian |
| El bucle | el hilo de composición de WPF | setAnimationLoop |
el bucle de R3F; useFrame ejecuta un callback en él |
| Pruebas de impacto | VisualTreeHelper.HitTest |
Raycaster |
onClick, onPointerMove en cualquier objeto |
| Liberación | el recolector de basura | dispose(), que llamas tú |
automática al desmontar, para lo que R3F creó |
Versiones
Sección titulada «Versiones»El curso fija @react-three/fiber 9.7.0 y @react-three/drei 10.7.8. React 19.3.0 salió el 9 de septiembre de 2026, pero R3F 9.7.0 declara una peer dependency de react >=19 <19.3: el curso fija react y react-dom 19.2.8. R3F 10 y drei 11 están en alfa (10.0.0-alpha.5 y 11.0.0-alpha.7), y no se usan aquí. R3F 8, que usa GuitarAlchemist, solo es compatible con React 18.
El tsconfig.json del curso añade "jsx": "react-jsx", que la transformación de Vite también sigue. Los tipos de los elementos de R3F vienen de su paquete: <mesh> se comprueba contra las propiedades de THREE.Mesh.
El diapasón como componente
Sección titulada «El diapasón como componente»Fretboard.tsx reconstruye el mástil de la lección 5, reutilizando su fretboard.ts para las posiciones de los trastes y las notas. El mástil es una malla con su geometría y su material como hijos (líneas 58-61):
<mesh name="neck" position={[NUT_X + NECK_LENGTH / 2, NECK_TOP - 0.06, 0]}> <boxGeometry args={[NECK_LENGTH, 0.12, NECK_WIDTH]} /> <meshStandardMaterial color={0x4a2c1d} roughness={0.75} /></mesh>Los trastes se pueden escribir de dos maneras, elegidas con la prop frets (líneas 62-71): 'inline' pone un <cylinderGeometry> y un <meshStandardMaterial> dentro de cada una de las 12 mallas de los trastes, como lo haría de forma natural un bucle que escribe JSX; 'shared' pasa a todas una geometría y un material, creados con useMemo (líneas 30-39):
// Creados una vez por componente, no una vez por render; R3F no libera lo que no creó, así que lo hace el efectoconst fretGeometry = useMemo(() => new THREE.CylinderGeometry(0.012, 0.012, NECK_WIDTH, 12).rotateX(Math.PI / 2), []);const fretMaterial = useMemo(() => new THREE.MeshStandardMaterial({ color: 0xc9c5bd, metalness: 1, roughness: 0.3 }), []);useEffect( () => () => { fretGeometry.dispose(); fretMaterial.dispose(); }, [fretGeometry, fretMaterial],);Los marcadores de un acorde de do mayor se calculan a partir de la prop markers con useMemo, y un contador registra cada vez que eso ocurre (líneas 41-45). La página pasa o bien una constante del módulo, o bien, con ?markers=literal, un array nuevo con el mismo contenido en cada render, como haría <Fretboard markers={[...]} />.
WebGPURenderer en un Canvas
Sección titulada «WebGPURenderer en un Canvas»<Canvas> crea un WebGLRenderer por defecto. R3F 9 acepta, como prop gl, una función que devuelve un renderizador o una promesa de uno; WebGPURenderer debe inicializarse antes de su primer fotograma, así que la función es async (main.tsx, líneas 40-52):
<Canvas // R3F 9 acepta una función que devuelve un renderizador, o una promesa de uno: WebGPURenderer debe inicializarse gl={async (props) => { const renderer = new THREE.WebGPURenderer({ ...(props as object), antialias: true, forceWebGL }); await renderer.init(); return renderer; }} camera={{ position: [-0.2, 2.4, 1.6], fov: 40 }} onCreated={(state) => { state.camera.lookAt(-0.2, 0.3, 0); control.state = state; }}>Las props contienen el canvas que creó R3F. La página importa three/webgpu, mientras que R3F y drei importan three. Los dos puntos de entrada comparten sus clases básicas, de three.core.js, así que un Mesh de uno es un Mesh para el otro, y WebGPURenderer convierte MeshStandardMaterial a su versión de nodos. Pero three también trae a la página WebGLRenderer y sus fragmentos de shader, que nunca usa (el build de la lección 13 lista los fragmentos).
Dos advertencias aparecen en la consola del navegador en cada ejecución, ambas de R3F 9.7.0 y no de la página:
THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.El store de R3F crea unClockpara eldeltadeuseFrame(events-156d8d12.esm.js, línea 1016).THREE.WebGPURenderer: PCFSoftShadowMap has been removed. Using PCFShadowMap instead., 14 veces en un probe en la máquina del autor, 13 enchromium-headless-shell. La propshadowsde<Canvas>valefalsepor defecto, un booleano, y R3F asignashadowMap.type = PCFSoftShadowMappara cualquier booleano; luegoWebGPURendererlo restablece aPCFShadowMapcon una advertencia (Renderer.js, líneas 1658-1664). R3F vuelve a configurar el renderizador cada vez que<Canvas>se renderiza, así que cada nuevo render delAppde la página advierte una vez más, y el recuento depende de cuántos eventos de puntero llegaron.check.shelimina la línea de la salida comparada, e imprime el recuento.
Medir un nuevo render
Sección titulada «Medir un nuevo render»La página informa de renderer.info y de los dos contadores tras tres fotogramas, luego vuelve a renderizar su App 10 veces, con un fotograma de separación, luego mueve el puntero sobre la cuerda 3, traste 5, y luego desmonta el diapasón (main.tsx, líneas 117-155). Cada nuevo render pasa por flushSync, que renderiza y hace commit antes de volver. Sin él, React agrupa las actualizaciones de estado que llegan antes de que pueda renderizar: en chromium-headless-shell, cuyo renderizador por software es lento, las 10 actualizaciones dieron 6 renders de Fretboard en lugar de 11.
$ node scripts/probe.mjs 09-r3f{ "frets": "shared", "markers": "stable", "first": { "drawCalls": 23, "triangles": 1453, "geometries": 12, "programs": 4, "meshes": 22, "fretboardRenders": 1, "markerBuilds": 1 }, "afterTenParentRenders": { "drawCalls": 23, "triangles": 1453, "geometries": 12, "programs": 4, "meshes": 22, "fretboardRenders": 11, "markerBuilds": 1 },22 mallas: el mástil, 12 trastes, 6 cuerdas y 3 marcadores; 23 draw calls con la pasada de salida. 12 geometrías: la del mástil, la compartida de los trastes, 6 cuerdas, 3 marcadores, y el triángulo de la pasada de salida. Los 10 renders del padre volvieron a ejecutar Fretboard 10 veces, ya que un componente de React se vuelve a renderizar con su padre salvo que esté envuelto en memo, y R3F comparó las props nuevas con las antiguas: nada cambió en la escena.
Con ?frets=inline&markers=literal:
$ node scripts/probe.mjs 09-r3f --query "frets=inline&markers=literal"{ "frets": "inline", "markers": "literal", "first": { "drawCalls": 23, "triangles": 1453, "geometries": 23, "programs": 4, "meshes": 22, "fretboardRenders": 1, "markerBuilds": 1 }, "afterTenParentRenders": { "drawCalls": 23, "triangles": 1453, "geometries": 23, "programs": 4, "meshes": 22, "fretboardRenders": 11, "markerBuilds": 11 },La misma imagen, con 23 geometrías en la GPU en lugar de 12: una por traste. Los draw calls no cambian, ya que cada traste ya era su propia malla. programs se queda en 4: los materiales idénticos comparten un shader. Y el useMemo de los marcadores volvió a ejecutarse en cada render, ya que su dependencia era un array nuevo cada vez. Aquí eso recalcula tres posiciones; R3F compara después los arrays position elemento por elemento y no cambia nada. El coste crece cuando un array nuevo reconstruye algo caro: una geometría, una textura o, como muestra la lección 13 en GuitarAlchemist, una escena entera.
R3F compara las props, pero compara args por referencia, elemento por elemento (events-156d8d12.esm.js, commitUpdate): <extrudeGeometry args={[shape, { depth: 0.3 }]} /> construye una geometría nueva en cada render, porque { depth: 0.3 } es un objeto nuevo cada vez. R3F 8.18.0 hace la misma comparación.
Eventos de puntero sin Raycaster
Sección titulada «Eventos de puntero sin Raycaster»R3F lanza los rayos desde el puntero por ti, y llama a onPointerMove, onClick y los demás en los objetos alcanzados, del más cercano al más lejano, con el impacto en el evento: event.point, event.object, event.distance. El grupo gestiona el evento para todos sus hijos (líneas 47-54):
function handlePointer(event: ThreeEvent<PointerEvent>) { // R3F lanza los rayos por nosotros: event.point es donde impactó el rayo, en coordenadas del mundo event.stopPropagation(); setHover(event.point.clone()); const fret = fretAt(event.point.x); const string = stringAt(event.point.z); onPick?.({ fret, string, note: fret === null ? null : noteName(string, fret) });}stopPropagation() detiene el evento en el objeto más cercano, como la lección 5 tomaba el primer impacto. La esfera de hover tiene raycast={() => null}, así que el rayo la atraviesa, como las layers de la lección 5. event.point es un vector que R3F reutiliza, de ahí el clone() antes de guardarlo en el estado.
Después de que Playwright mueva el ratón real sobre la cuerda 3, traste 5:
"after": { "string 3, fret 5": { "status": "parent renders: 10, pick: C4", "label": "C4", "drawCalls": 24, "triangles": 1677, "geometries": 13, "programs": 4, "meshes": 23 },Los contadores ya no se informan después de que se mueva el puntero: el navegador agrupa los eventos pointermove cuando los fotogramas son lentos, así que el número de renders que causan difiere de una máquina a otra. La etiqueta es el <Html> de drei: un div posicionado sobre el canvas en un punto 3D, y desplazado en cada fotograma. No está en los draw calls; la esfera de hover es el draw call número 24.

Página en vivo: ejecutarla aquí
Abrir a pantalla completa. Pruébalo: elige literal en markers, el caso del ejercicio 3, o inline en frets; marca drei’s Text para la captura siguiente. La escena de voicings también es una página R3F: el port de la lección 13 en un Canvas con un WebGPURenderer, y un panel lil-gui, fuera de React, que elige el acorde.
Lo que libera el desmontaje
Sección titulada «Lo que libera el desmontaje» "unmount the fretboard": { "status": "parent renders: 10, pick: C4", "label": "C4", "drawCalls": 1, "triangles": 1, "geometries": 1, "programs": 2, "meshes": 0 } }}La etiqueta se queda: pertenece a App, que conserva la última selección. Queda una geometría, la de la pasada de salida. R3F liberó las geometrías y los materiales que creó a partir del JSX: cuando se elimina un elemento, llama a dispose() en su objeto y en los de sus hijos, con prioridad idle en un navegador y de inmediato en una prueba (la página espera 250 ms) (removeChild). No libera los objetos pasados como props, como la geometría compartida de los trastes, ni <primitive object={...} />, que pueden pertenecer a otro sitio: el efecto del componente libera esos. Sin ese efecto, el recuento de geometrías se quedaría en 2, y un componente montado y desmontado en un bucle perdería una geometría cada vez. dispose={null} en un elemento lo excluye.
drei en WebGPU
Sección titulada «drei en WebGPU»Los componentes de drei están escritos para WebGLRenderer. Los que se usan aquí funcionan con WebGPURenderer: OrbitControls solo mueve la cámara, y Html es DOM. <Text> no funciona. Con ?text, dibuja un rectángulo pálido y sólido donde deberían estar las palabras “C major”, y los programas pasan de 4 a 6:

Página en vivo: ejecutarla aquí
<Text> usa troika-three-text 0.52.5, que dibuja los glifos con un shader de campo de distancia con signo inyectado mediante material.onBeforeCompile (troika-three-utils). WebGPURenderer nunca llama a onBeforeCompile: construye sus shaders a partir de nodos. El quad es el material base, sin el texto. En WebGPU, el texto necesita otra vía: una textura de canvas, Html o un material TSL (por verificar: si existe una versión de troika preparada para WebGPU).
En GuitarAlchemist/ga
Sección titulada «En GuitarAlchemist/ga»- R3F 8, React 18, WebGL. Los componentes React de GA dependen de
@react-three/fiber^8.18.0, drei^9.122.0y React^18.3.1(package.json, líneas 43-65). Seis archivos renderizan un<Canvas>, todos con elWebGLRendererpor defecto; 9 archivos usan R3F, de unos 146 que usan three.js. El resto, incluidoThreeFretboard.tsx, crean su renderizador en unuseEffecty gestionan la escena a mano. Pasar a R3F 9 significa pasar antes a React 19. - Una actualización de estado en cada fotograma. El
Playerdel explorador BSP llama aonPositionChange(camera.position)al final de suuseFrame, se haya movido el jugador o no (Player.tsx, líneas 121-144), yBSPDoomExplorerV2responde consetPlayerPosition(pos.clone())(BSPDoomExplorerV2.tsx, líneas 77-79): un objeto nuevo en el estado, así que React vuelve a renderizar el explorador y sus hijos 60 veces por segundo. El consejo de R3F es guardar los valores por fotograma en refs, y actualizar el estado solo cuando cambia algo visible, aquí la región, que el mismo callback ya detecta. Cuánto tiempo toman esos renders está por verificar con el profiler de React. argscon un objeto nuevo.GuitarAlchemistLogo3D.tsxdeclara sus ajustes de extrusión como un objeto literal en el cuerpo del componente y los pasa enargs(líneas 31-42): cada render del logo construye unaExtrudeGeometrynueva. La forma de al lado está en unuseMemo; los ajustes también necesitan uno, o una constante del módulo.
Experimento GA
Sección titulada «Experimento GA»El experimento 14 de la lección 14 dibuja la misma escena con R3F y sin él: ningún coste por fotograma, pero 430 ms más para el primer render en el servidor de desarrollo, 10 MB de heap y un bundle de 565 kB gzip frente a 210, porque R3F también importa three, el build WebGL.
Puntos clave
Sección titulada «Puntos clave»- Los elementos JSX de R3F son objetos de three.js: las props se convierten en propiedades,
argsen argumentos del constructor, y un elemento deargscambiado construye un objeto nuevo. <Canvas gl>acepta una factoría asíncrona: así se inicializaWebGPURendererantes de que R3F renderice.- Un nuevo render del padre vuelve a ejecutar el componente; R3F cambia después solo las props que difieren. Cuenta los renders y las reconstrucciones en lugar de adivinar: aquí 10 renders del padre no costaron nada en la escena.
- Las geometrías en línea dentro de un bucle multiplican los búferes de la GPU; compártelas con
useMemo, y libéralas tú mismo, ya que R3F solo libera lo que creó. useMemocon un array nuevo como dependencia se ejecuta en cada render: pasa valores estables, o compara los contenidos.- El
Textde drei no se renderiza enWebGPURendereren drei 10.7.8.
Ejercicios
Sección titulada «Ejercicios»- El
Appde la página pasaonPick={setPick}. EnvuelveFretboardenmemoy cuenta sus renders después de los 10 renders del padre. ¿Qué impide quememofuncione si la página pasa en su lugaronPick={(pick) => setPick(pick)}? - Elimina el
useEffectque libera la geometría y el material compartidos de los trastes, y predice los recuentos después del desmontaje. Luego compruébalo con el probe. - Con
?markers=literal, haz quemarkerBuildsse quede en 1 sin cambiar la página: compara el contenido de los marcadores en lugar de su identidad.
Solución 1
const MemoFretboard = memo(Fretboard): memo omite un render cuando cada prop es igual, según Object.is, a la anterior. frets es una cadena, markers la constante del módulo, y onPick es setPick, un setter de estado, que React mantiene estable. Una función flecha escrita en el JSX es una función nueva en cada render: memo ve una prop cambiada y renderiza cada vez. useCallback la mantiene estable. Con ?markers=literal, el array de marcadores también es nuevo en cada render, y memo tampoco ayuda.
Comprobado con @react-three/test-renderer (lección 12), en una prueba desechable que vuelve a renderizar un padre 10 veces, no con el probe del navegador: fretboardRenders vale 1 con onPick={setPick}, y 11 con onPick={(pick) => setPick(pick)}.
Solución 2
R3F sigue liberando las geometrías del mástil y de las cuerdas, que creó a partir del JSX. La geometría compartida de los trastes se pasó como prop, así que nadie la libera. La última prueba de la lección 12 cuenta las llamadas a BufferGeometry.dispose después de un desmontaje: 8 con el efecto; en una copia desechable del componente sin él, 7. En el navegador, geometries debería indicar entonces 2 después del desmontaje en lugar de 1 (por verificar con el probe). El material no tiene búfer propio en la GPU, así que renderer.info no lo muestra; su pipeline sigue en caché mientras vive. Monta y desmonta el componente en un bucle, y el recuento crece en uno cada vez: esa es la fuga que la lección 13 encuentra en los sprites de GA.
Solución 3
Deriva una clave del contenido, y haz que dependa de ella:
const markersKey = markers.map(({ string, fret }) => `${string}:${fret}`).join(',');const markerPositions = useMemo(() => { counters.markerBuilds++; return markers.map(({ string, fret }) => pressPoint(string, fret).toArray()); // eslint-disable-next-line react-hooks/exhaustive-deps}, [markersKey]);La regla de lint se queja, ya que markers se usa sin ser una dependencia; la clave es aquello de lo que depende el memo. En una prueba desechable con este cambio, seis renders con una copia nueva del acorde cada vez construyeron los marcadores una sola vez, y un acorde distinto los construyó una segunda vez. El port de la lección 13 hace lo mismo con las positions de GA. La alternativa es un array estable en el padre: una constante del módulo, o un useMemo allí.
Fuentes
Sección titulada «Fuentes»- React Three Fiber: introducción, objetos, propiedades y argumentos del constructor,
Canvas, eventos, trampas de rendimiento, guía de migración a v9 - drei:
Html,Text, controles - React:
useMemo,memo,useCallback - Código fuente de three.js en r186:
Renderer.js - El build de R3F 9.7.0, tal como se publicó en npm:
events-156d8d12.esm.js