Texturas de partícula: gl_PointCoord, alfa y el orden que nunca cuadra
Cómo se mapea una textura sobre un point sprite, por qué las partículas transparentes se tapan entre sí y las tres estrategias reales para que el resultado no dependa del orden del buffer.
De dónde sale la UV de un punto
Un point sprite no tiene atributo uv que interpolar entre vértices, porque solo hay un vértice. La GPU aporta en su lugar la variable gl_PointCoord, que dentro del fragment shader vale (0,0) en una esquina del cuadrado y (1,1) en la contraria. El chunk de Three.js que muestrea map y alphaMap en un Points hace exactamente esto:
vec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;Dos cosas importan ahí. La primera es el 1.0 - gl_PointCoord.y: la coordenada del sprite crece hacia abajo y la de una textura de Three.js crece hacia arriba, así que hay que invertirla. La segunda es uvTransform, que es la matriz que sale de repeat, offset, rotation y center de la textura. Como se aplica igual que en cualquier otro material, un atlas de sprites funciona sin más:
const atlas = new THREE.TextureLoader().load('particulas.png');atlas.colorSpace = THREE.SRGBColorSpace;atlas.repeat.set(0.25, 0.25); // rejilla de 4 por 4atlas.offset.set(0.5, 0.25); // celda columna 2, fila 1Eso escoge una celda para toda la nube, no una por partícula, porque uvTransform es un uniform. Variar la celda partícula a partícula exige, otra vez, un shader propio con un atributo que aporte el desplazamiento. El shader de Three.js contempla además una ruta alternativa, activa cuando la geometría del Points trae un atributo uv por vértice: en ese caso se usa esa UV interpolada en lugar de gl_PointCoord, lo que convierte el sprite entero en un texel constante y sirve precisamente para el caso del atlas por partícula.
La textura, sin fichero
Para una partícula redonda difuminada no hace falta descargar nada: un degradado radial en un canvas y una CanvasTexture bastan, y de paso te ahorras una petición de red y el problema de la ruta relativa.
import * as THREE from 'three';function texturaDeParticula(lado = 128) { const canvas = document.createElement('canvas'); canvas.width = canvas.height = lado; const ctx = canvas.getContext('2d'); const g = ctx.createRadialGradient( lado / 2, lado / 2, 0, lado / 2, lado / 2, lado / 2 ); g.addColorStop(0.0, 'rgba(255,255,255,1)'); g.addColorStop(0.35, 'rgba(255,255,255,0.45)'); g.addColorStop(1.0, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, lado, lado); const textura = new THREE.CanvasTexture(canvas); textura.colorSpace = THREE.SRGBColorSpace; return textura;}const material = new THREE.PointsMaterial({ map: texturaDeParticula(), color: 0xfab387, size: 0.5, sizeAttenuation: true, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending});El colorSpace no es decorativo: una textura de color hay que marcarla como sRGB o el resultado sale lavado. Es un tema de por sí y tiene su nivel.
El problema del orden, con nombre y apellidos
Cuando transparent es true, Three.js dibuja el objeto en la pasada de transparentes y ordena esa pasada por distancia a la cámara, de lejos a cerca. Esa ordenación es por objeto. Dentro de un Points, las partículas se dibujan en el orden en que están en el Float32Array, que es el orden en que las generaste, que no tiene ninguna relación con la profundidad.
Si además dejas depthWrite en su valor por defecto, true, cada partícula escribe su profundidad en el z-buffer, así que una partícula cercana dibujada temprano impide que se dibujen las lejanas que caen detrás de su cuadrado. Y como el cuadrado es un cuadrado aunque la textura sea redonda, lo que ves son recortes rectangulares alrededor de cada partícula. Es el artefacto más reconocible del 3D web mal hecho.
Hay tres salidas y ninguna es universal.
Mezcla aditiva con depthWrite: false. La suma es conmutativa, así que el resultado no depende del orden. Es correcta por construcción, no una aproximación. Sirve para todo lo que emite luz: fuego, chispas, estrellas, brillos, energía. No sirve para humo ni polvo, porque lo aditivo nunca oscurece: una nube de humo denso tiene que tapar lo que hay detrás, y sumando solo se aclara.
alphaTest con depthWrite: true. El fragmento se descarta si el alfa queda por debajo del umbral, así que no hay mezcla y no hay problema de orden: es geometría opaca con agujeros. El borde es duro, sin degradado, lo cual para chispas duras o copos de nieve es perfectamente aceptable y para humo es horrible. Tiene una ventaja seria: al escribir profundidad, las partículas se ordenan solas contra el resto de la escena.
Ordenar tú. Recalcular cada fotograma la distancia de cada partícula a la cámara y reescribir el Float32Array en ese orden. Es correcto para el caso general y cuesta un sort de N elementos por fotograma más la subida del buffer a la GPU. Con dos mil partículas es viable; con cincuenta mil no. Si vas a llegar ahí, la respuesta real es cambiar de técnica y no de ordenación.