Cómo crear un campo de partículas 3D con React Three Fiber
Yaxito · 6 min de lectura
Por qué React Three Fiber
@react-three/fiber (R3F) es un renderer de React para Three.js: en vez de crear la escena a mano con new THREE.Scene(), new THREE.Mesh(), etc., la describes con componentes JSX (<mesh>, <points>, <bufferGeometry>…) y R3F se encarga de sincronizarlos con el árbol de Three.js por debajo. Ganas el modelo mental de React —props, hooks, composición— sin perder acceso a la API completa de Three.js cuando la necesitas.
Un campo de partículas es un buen primer proyecto porque toca las piezas clave: geometría por buffers, materiales, y animación por frame con useFrame.
La idea: puntos repartidos en una esfera
Un THREE.Points necesita un BufferGeometry con un atributo position: un Float32Array plano de [x0, y0, z0, x1, y1, z1, ...]. La tentación es generar x, y, z aleatorios dentro de un cubo, pero eso agrupa partículas visualmente en las esquinas y aristas del cubo — la densidad no es uniforme.
La solución es generar las posiciones en coordenadas esféricas y convertirlas a cartesianas:
const radius = 4 + Math.random() * 6 // entre 4 y 10 unidades del centro
const theta = Math.random() * Math.PI * 2 // ángulo horizontal, 0 a 2π
const phi = Math.acos(Math.random() * 2 - 1) // ángulo vertical, con acos para distribución uniforme
const x = radius * Math.sin(phi) * Math.cos(theta)
const y = radius * Math.sin(phi) * Math.sin(theta)
const z = radius * Math.cos(phi)
El detalle importante es Math.acos(Math.random() * 2 - 1) en vez de Math.random() * Math.PI: sin el acos, las partículas se amontonan en los polos de la esfera. Con él, la distribución es uniforme sobre toda la superficie.
El componente completo
import { useMemo, useRef } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import * as THREE from 'three'
const PARTICLE_COUNT = 900
const PALETTE = ['#fa87a8', '#ffad8a', '#ee81e9', '#b9f987']
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
function ParticleField() {
const pointsRef = useRef<THREE.Points>(null)
const { positions, colors } = useMemo(() => {
const pos = new Float32Array(PARTICLE_COUNT * 3)
const col = new Float32Array(PARTICLE_COUNT * 3)
const tmpColor = new THREE.Color()
for (let i = 0; i < PARTICLE_COUNT; i++) {
const radius = 4 + Math.random() * 6
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(Math.random() * 2 - 1)
pos[i * 3] = radius * Math.sin(phi) * Math.cos(theta)
pos[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta)
pos[i * 3 + 2] = radius * Math.cos(phi)
tmpColor.set(PALETTE[i % PALETTE.length])
col[i * 3] = tmpColor.r
col[i * 3 + 1] = tmpColor.g
col[i * 3 + 2] = tmpColor.b
}
return { positions: pos, colors: col }
}, [])
useFrame((_, delta) => {
if (!pointsRef.current || prefersReducedMotion()) return
pointsRef.current.rotation.y += delta * 0.02
pointsRef.current.rotation.x += delta * 0.005
})
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial
size={0.05}
vertexColors
transparent
opacity={0.8}
sizeAttenuation
depthWrite={false}
/>
</points>
)
}
export default function Hero3D() {
return (
<Canvas camera={{ position: [0, 0, 7], fov: 45 }} dpr={[1, 1.5]} gl={{ antialias: true, alpha: true }}>
<ParticleField />
</Canvas>
)
}
Los detalles que marcan la diferencia
useMemopara las posiciones. Generarlas una vez y reutilizar el mismoFloat32Arrayen cada render evita recalcular 900 puntos (2700 floats) en cada frame — solo se calculan al montar el componente.useFrame((_, delta) => ...)en vez de un contador manual. El segundo argumento deuseFramees el tiempo transcurrido desde el frame anterior. Multiplicar pordeltahace que la velocidad de rotación sea la misma en una pantalla a 60 Hz que en una a 144 Hz — sin esto, el giro iría más rápido en monitores con mayor framerate.- Respetar
prefers-reduced-motion. Comprobarlo dentro deuseFramey cortar la animación ahí es más simple que desmontar elCanvasentero, y evita mover innecesariamente a usuarios que han pedido explícitamente menos movimiento en pantalla. depthWrite={false}en el material. Sin esto, las partículas más cercanas a la cámara pueden ocultar de forma abrupta a las lejanas al solaparse, generando parpadeos. Desactivar la escritura en el buffer de profundidad (mientras sigue leyendo de él) da una mezcla más suave entre partículas semitransparentes.sizeAttenuation. Hace que las partículas más lejanas de la cámara se vean más pequeñas, reforzando la sensación de profundidad en la esfera.
Cuándo cargar esto en una página real
Three.js y React Three Fiber no son gratis en peso ni en CPU/GPU. Si lo usas como fondo decorativo (un hero, por ejemplo), tiene sentido:
- Cargarlo como isla con hidratación diferida (
client:idleo, mejor aún,client:media="(min-width: 768px)"si en móvil no aporta y prefieres ahorrar batería y JS). - Ponerlo en
aria-hidden="true"si es puramente decorativo, para que no interfiera con lectores de pantalla. - Vigilar
PARTICLE_COUNT: 900 va sobrado en la mayoría de portátiles y móviles de gama media-alta, pero si notas caídas de frames en dispositivos más limitados, es el primer parámetro que hay que bajar.