Points y PointsMaterial: un vértice, un cuadrado
Cómo la GPU convierte un único vértice en un fragmento visible, qué contiene exactamente PointsMaterial y cómo se monta un sistema de partículas con una sola BufferGeometry.
Un Mesh necesita tres vértices para producir un triángulo, y un cuadrado texturizado le cuesta cuatro vértices y seis índices. Points produce un fragmento visible con un solo vértice, y esa diferencia de proporción es lo que permite dibujar cien mil partículas donde no cabrían cien mil quads. El precio de esa economía es severo: no hay normales, no hay iluminación, no hay caras y el tamaño deja de medirse en unidades del mundo para medirse en píxeles.
- Explicar cómo el rasterizador expande un vértice en un cuadrado alineado a pantalla.
- Enumerar las propiedades reales de
PointsMaterialy las que no existen porque no tendrían sentido. - Construir un sistema de partículas con atributos
positionycolorsobre una únicaBufferGeometry. - Anticipar las consecuencias de que un
Pointscompleto sea un solo objeto para culling y raycast.
La primitiva que no es un triángulo
WebGL sabe dibujar tres familias de primitivas: triángulos, líneas y puntos. Points usa la tercera. Cuando envías una geometría con el modo POINTS, el pipeline procesa un vértice por invocación del vertex shader como siempre, pero el rasterizador no busca compañeros con los que formar un triángulo: toma la posición en clip space del vértice, la proyecta a pantalla, y genera un cuadrado centrado en ese punto cuyo lado en píxeles es el valor que el vertex shader escribió en la variable de salida gl_PointSize.
Ese cuadrado se llama point sprite y siempre está alineado a la pantalla. No rota con la cámara, no tiene grosor, no tiene lados y no puede ser culleado por orientación porque no hay orientación que culear: material.side no significa nada en un Points. Tampoco hay normal, y sin normal no hay producto escalar con la dirección de la luz, así que no hay iluminación posible. Por eso PointsMaterial no tiene emissive, ni roughness, ni envMap: el color de un punto sale de tres sitios y solo tres, el color del material, el atributo color del vértice si activas vertexColors, y la textura map.
La coordenada dentro de ese cuadrado la da la GPU en gl_PointCoord, que va de cero a uno en las dos direcciones. Es la única UV que existe por defecto en un punto, y es lo que Three.js usa para muestrear map y alphaMap.
Qué contiene PointsMaterial
El material es corto de verdad. Sobre las propiedades comunes de Material añade exactamente seis: color, map, alphaMap, size, sizeAttenuation y fog. Nada más. Si escribes new THREE.PointsMaterial({ roughness: 0.5 }) Three.js no lanza una excepción, imprime un aviso por consola diciendo que roughness no es una propiedad de THREE.PointsMaterial y sigue adelante; el aviso es fácil de perder entre el ruido y el material se queda como estaba.
size vale 1 por defecto y se interpreta en píxeles cuando sizeAttenuation es false. sizeAttenuation vale true por defecto, y entonces el tamaño se divide por la distancia a la cámara para que los puntos lejanos se vean más pequeños. Los dos casos tienen su lección propia porque el detalle importa más de lo que parece.
alphaMap merece un apunte: Three.js muestrea de esa textura el canal verde, no la luminancia ni el alfa. La razón es histórica y sigue siendo válida: en los formatos comprimidos tipo DXT y en el RGB565 sin comprimir, el verde tiene un bit más de precisión que el rojo y el azul, así que es el canal más fiable para guardar una máscara de un solo valor. Si escribes tu máscara solo en el rojo, verás negro.
Montar el sistema de partículas
Todo el sistema es una BufferGeometry con un atributo position y, si quieres color por partícula, un atributo color. No hay índice porque no hay vértices que compartir: cada vértice es una partícula independiente.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.z = 9;
const COUNT = 50000;
const positions = new Float32Array(COUNT * 3);
const colors = new Float32Array(COUNT * 3);
const c = new THREE.Color();
for (let i = 0; i < COUNT; i++) {
// Distribucion uniforme sobre una capa esferica.
const radio = 3 + Math.random() * 1.5;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i * 3 + 0] = radio * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = radio * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = radio * Math.cos(phi);
c.setHSL(i / COUNT, 0.7, 0.6, THREE.SRGBColorSpace);
colors[i * 3 + 0] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.04,
sizeAttenuation: true,
vertexColors: true
});
const nube = new THREE.Points(geometry, material);
scene.add(nube);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
renderer.setAnimationLoop(() => {
nube.rotation.y += 0.002;
renderer.render(scene, camera);
});
Fíjate en el acos(2 * random - 1) para el ángulo polar. Si usaras random() * PI las partículas se acumularían en los polos, porque el área de una banda esférica no es proporcional al ángulo. Es el error clásico de la primera nube de puntos de todo el mundo y se ve a simple vista.
Cincuenta mil partículas ahí son una llamada de dibujo. Ese es el argumento entero de Points: cincuenta mil Mesh serían cincuenta mil llamadas y la CPU se moriría mucho antes de que la GPU se enterase.
Todo lo que Three.js decide por objeto lo decide para la nube entera. El frustum culling usa una única boundingSphere que envuelve las cincuenta mil partículas: si asoma una sola por el borde de la pantalla, se envían las cincuenta mil. El renderOrder es uno para todas. La ordenación de transparentes ordena tu nube respecto a otros objetos, nunca las partículas entre sí. Y si mueves posiciones en el Float32Array, geometry.computeBoundingSphere() no se llama solo: la esfera se queda con el tamaño viejo y empiezas a ver la nube desaparecer entera cuando giras la cámara. Si tus partículas se mueven mucho, o recalculas la esfera cada cierto tiempo, o pones nube.frustumCulled = false y te ahorras el problema.
Raycast, coste y el punto de ruptura
Points sí implementa raycast, pero no puede hacer lo que hace un triángulo: un punto matemático nunca intersecta exactamente con un rayo. Lo que hace es medir la distancia del rayo a cada vértice y aceptar los que caen por debajo de un umbral configurable en raycaster.params.Points.threshold, cuyo valor por defecto es 1 en unidades del mundo. Con una nube de escala pequeña ese umbral selecciona media nube; con una escala grande no selecciona nada. Es prácticamente obligatorio tocarlo.
const raycaster = new THREE.Raycaster();
raycaster.params.Points.threshold = 0.05; // acorde a la escala de la nube
El coste del raycast es lineal en el número de partículas y se ejecuta en CPU, así que cincuenta mil partículas por movimiento del ratón son cincuenta mil raíces cuadradas. Hay un descarte previo contra la boundingSphere, pero dentro de ella se recorre todo. Para nubes grandes con interacción, lo razonable es no usar raycast y resolver la selección con una pasada de render a textura con identificadores por color, o con una rejilla espacial propia.
En cuanto a rendimiento de dibujo, el cuello de Points casi nunca es el vertex shader: es el fill rate. Cada partícula pinta un cuadrado de gl_PointSize al cuadrado píxeles, y si son transparentes, esos píxeles se leen y se escriben con mezcla. Diez mil partículas de sesenta píxeles de lado son treinta y seis millones de fragmentos por fotograma, cuatro veces la pantalla completa en una 4K. Reducir el tamaño baja más el coste que reducir el número.
Sustituye el bucle de posiciones por una espiral de Fibonacci sobre la esfera (el ángulo dorado, phi = acos(1 - 2*(i+0.5)/COUNT) y theta = i * PI * (1 + sqrt(5))) y compara la distribución con la aleatoria. Después mide con el panel de rendimiento del navegador cuánto tarda el bucle de generación para un millón de partículas y decide si merece la pena moverlo a un Worker.