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.
Una nube de puntos cuadrados no se parece a nada. En cuanto le pones una textura con alfa empieza a parecer humo, chispas o estrellas, y en cuanto le pones alfa aparece el problema que arrastra todo el renderizado en tiempo real desde hace treinta años: la mezcla de transparencias no es conmutativa, depende del orden, y dentro de un Points no tienes ningún control sobre el orden.
- Explicar de dónde salen las coordenadas de textura de un point sprite.
- Diagnosticar por qué unas partículas recortan a otras con bordes cuadrados.
- Elegir entre mezcla aditiva,
alphaTesty ordenación explícita según el efecto. - Generar una textura de partícula por código, sin depender de un fichero externo.
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 4
atlas.offset.set(0.5, 0.25); // celda columna 2, fila 1
Eso 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.
Poner depthWrite: false sigue dejando depthTest: true, que es lo que quieres: las partículas no escriben profundidad entre ellas pero sí se comparan contra la profundidad que ya escribió la geometría opaca, así que una nube de chispas detrás de una pared queda correctamente oculta. Si además desactivas depthTest, las partículas se pintan encima de todo, incluida la pared. Eso solo tiene sentido para elementos de interfaz.
La mezcla aditiva suma valores de color sin techo, así que veinte partículas superpuestas dan un valor de veinte, muy por encima del uno. Si el renderer tiene toneMapping activado, esa curva comprime el rango alto y el resultado es que el centro de tu explosión, donde más partículas se solapan, se vuelve blanco plano y pierde todo el color: has saturado el mapeo de tonos, no el buffer. La solución no es bajar la opacidad de las partículas, es bajar renderer.toneMappingExposure o repartir el brillo en más partículas más tenues. Y si estás componiendo un efecto que debe verse igual con y sin tone mapping, material.toneMapped = false lo saca de la curva por completo, con la contrapartida de que ya no encajará tonalmente con el resto de la escena.
Monta dos nubes idénticas de dos mil partículas con la misma textura difuminada, una con AdditiveBlending y depthWrite: false y otra con NormalBlending y depthWrite: true. Gíralas y localiza el fotograma exacto en el que aparecen los recortes cuadrados en la segunda. Después cambia la segunda a alphaTest: 0.5 sin transparencia y comprueba que los recortes desaparecen y aparece el borde duro.