El tamaño de un punto: sizeAttenuation y el techo del hardware
La fórmula exacta que Three.js escribe en gl_PointSize, qué hace de verdad sizeAttenuation, por qué el FOV no afecta a los puntos y dónde está el límite que impone el driver.
size: 0.1 en un PointsMaterial no significa “diez centímetros” ni “diez píxeles”: significa un número que entra en una fórmula corta del vertex shader junto con el device pixel ratio, la altura del lienzo y la distancia a la cámara. Conocer esa fórmula de memoria es la diferencia entre ajustar el tamaño a ojo hasta que quede bien en tu portátil y saber por qué en el móvil del cliente se ve el doble de grande.
- Reproducir la fórmula que Three.js usa para calcular
gl_PointSize. - Distinguir el comportamiento con
sizeAttenuationactivado y desactivado, y con cámara ortográfica. - Consultar el límite de tamaño de punto del dispositivo y detectar cuándo lo estás rebasando.
- Escribir un
ShaderMaterialque dé un tamaño distinto a cada partícula.
La fórmula, entera
El vertex shader de Points en r184 hace esto y nada más:
gl_PointSize = size;
#ifdef USE_SIZEATTENUATION
bool isPerspective = isPerspectiveMatrix( projectionMatrix );
if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );
#endif
Los dos uniforms que entran ahí los rellena el renderer justo antes de dibujar:
uniforms.size.value = material.size * pixelRatio;
uniforms.scale.value = height * 0.5;
donde pixelRatio es lo que le pasaste a renderer.setPixelRatio() y height es la altura en píxeles CSS que le pasaste a renderer.setSize(). Junta las tres piezas y el tamaño final en píxeles físicos de un punto es:
gl_PointSize = material.size * pixelRatio * (alturaCSS / 2) / distanciaEnZ
De ahí salen todas las consecuencias prácticas. Con sizeAttenuation activado, material.size se comporta como una medida de mundo: un valor de 0.04 en una escena cuyo radio típico es 4 da partículas de un centésimo del tamaño de la escena, y ese aspecto se mantiene aunque cambies la resolución de la ventana, porque el tamaño en píxeles crece con la altura del lienzo igual que crece el tamaño en píxeles de cualquier objeto de la escena.
Con sizeAttenuation desactivado no se aplica nada de eso: gl_PointSize es directamente material.size * pixelRatio. Es decir, material.size está en píxeles CSS. Es el modo correcto para marcadores de interfaz, nubes de datos y cualquier cosa que deba tener el mismo tamaño esté donde esté. Y ojo: el pixelRatio sigue multiplicando, que es justo lo que quieres, porque así el punto ocupa el mismo espacio visual en una pantalla retina que en una normal.
Mira otra vez: no hay ningún tan(fov/2) por ninguna parte. La proyección en perspectiva escala las coordenadas de todo lo demás por el inverso de la tangente del medio FOV, pero gl_PointSize se calcula al margen de la matriz de proyección salvo por el signo de z. Consecuencia directa y comprobable en treinta segundos: si animas camera.fov para hacer un zoom, la geometría de la escena crece y los puntos no. Si tu efecto depende de un zoom por FOV, tienes que compensar el tamaño de las partículas a mano multiplicando por 1 / Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2), o usar un ShaderMaterial que aplique la escala correcta. Lo mismo pasa con camera.zoom.
Con cámara ortográfica el if no entra nunca, porque isPerspectiveMatrix comprueba el elemento de la matriz de proyección que distingue una de otra. Con una ortográfica, sizeAttenuation no hace absolutamente nada y todos los puntos miden lo mismo, estén cerca o lejos. No es un bug: en una proyección ortográfica no hay perspectiva que atenuar.
El techo que pone el driver
gl_PointSize no admite cualquier valor. La implementación de WebGL declara un rango soportado que puedes consultar, y cualquier valor por encima se recorta en silencio:
const gl = renderer.getContext();
const rango = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);
console.log('tamano de punto soportado:', rango[0], 'a', rango[1]);
En la mayoría de GPU de escritorio ese máximo está entre 255 y 1024 píxeles, pero hay hardware móvil y drivers integrados que lo dejan en 63 o 64. El síntoma es inconfundible: las partículas crecen normalmente al acercarse a la cámara y de repente dejan de crecer, todas a la vez, mientras el resto de la escena sigue acercándose. Si tu efecto consiste en volar a través de un campo de partículas, ese es el momento en que se rompe.
Hay un segundo problema del que casi nadie avisa. El recorte contra el frustum se hace sobre el vértice, no sobre el sprite. Un punto cuyo centro sale por el borde de la pantalla se descarta entero, aunque la mitad de su cuadrado debería seguir siendo visible. Con puntos pequeños no se nota; con puntos grandes se ve un parpadeo en los cuatro bordes de la pantalla. La solución no es de Three.js: si necesitas partículas grandes y estables en los bordes, deja de usar Points y pasa a quads instanciados con InstancedMesh, que sí se recortan correctamente porque tienen geometría real.
Un tamaño por partícula
PointsMaterial tiene un único size para toda la nube porque es un uniform. Para variar el tamaño partícula a partícula hace falta un atributo, y con un atributo hace falta un shader propio. Reproducir la fórmula de Three.js dentro de él es trivial ahora que la conoces:
import * as THREE from 'three';
const COUNT = 20000;
const positions = new Float32Array(COUNT * 3);
const sizes = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 10;
positions[i * 3 + 1] = (Math.random() - 0.5) * 10;
positions[i * 3 + 2] = (Math.random() - 0.5) * 10;
// Distribucion sesgada: muchas pequenas, pocas grandes.
sizes[i] = 0.02 + Math.pow(Math.random(), 4) * 0.35;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
const pixelRatio = Math.min(window.devicePixelRatio, 2);
const material = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uPixelRatio: { value: pixelRatio },
uAlturaCSS: { value: window.innerHeight },
uColor: { value: new THREE.Color(0x94e2d5) }
},
vertexShader: `
attribute float aSize;
uniform float uPixelRatio;
uniform float uAlturaCSS;
void main() {
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = aSize * uPixelRatio * ( uAlturaCSS * 0.5 ) / -mvPosition.z;
}
`,
fragmentShader: `
uniform vec3 uColor;
void main() {
// Recorta el cuadrado a un disco y suaviza el borde.
vec2 centrado = gl_PointCoord - 0.5;
float d = length( centrado );
float alfa = smoothstep( 0.5, 0.35, d );
if ( alfa < 0.01 ) discard;
gl_FragColor = vec4( uColor, alfa );
}
`
});
const nube = new THREE.Points(geometry, material);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(pixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.add(nube);
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.z = 12;
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
// Sin esto, el tamano de las particulas se descuadra al redimensionar.
material.uniforms.uAlturaCSS.value = window.innerHeight;
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
Dos detalles que se olvidan siempre. El primero es actualizar uAlturaCSS en el resize: si no lo haces, el tamaño de las partículas deja de coincidir con el de la escena en cuanto el usuario cambia el tamaño de la ventana. El segundo es el discard del fragment shader: sin él, cada partícula pinta su cuadrado completo y las esquinas transparentes siguen costando fill rate y, con depthWrite activado, siguen escribiendo profundidad. El discard no es gratis en todas las GPU (deshabilita el early-Z para ese material), pero para partículas aditivas con depthWrite: false el early-Z tampoco iba a servir de nada.
Añade un uniform uTiempo y haz que el tamaño de cada partícula pulse con una fase distinta por partícula: guarda una fase aleatoria en un segundo atributo y multiplica aSize por 0.6 + 0.4 * sin(uTiempo + aFase). Después mide con el panel de rendimiento cuánto sube el coste al doblar el tamaño medio y confirma que el cuello está en el fragment shader y no en el vertex.