wandres.dev
CÓMO SE DIBUJA UN PÍXEL · El pipeline gráfico

Teselación y geometría: las etapas que la web decidió no tener

Qué hacen las etapas de amplificación de geometría en el hardware de escritorio, por qué ni WebGL2 ni WebGPU las exponen, y con qué se sustituyen en la práctica.

⏱ 16 min

Entre el vertex shader y el rasterizador, el hardware de escritorio tiene dos etapas más que ninguna API web expone. No es un olvido ni una carencia temporal: es una decisión deliberada basada en cómo se comportan esas etapas en el hardware real, especialmente el de los móviles. Entender qué hacían y por qué se quedaron fuera te evita buscar durante horas una funcionalidad que no va a llegar, y te enseña con qué se resuelve hoy el mismo problema.

🎯 Al terminar esta lección sabrás
  • Describir qué hace la teselación y qué hace el geometry shader.
  • Explicar por qué la amplificación de geometría rompe el modelo de paralelismo regular.
  • Justificar por qué ni WebGL2 ni WebGPU las incluyen.
  • Aplicar las tres alternativas reales para amplificar geometría en la web.

Qué hacían esas dos etapas

La teselación subdivide una primitiva en muchas primitivas más pequeñas, en la GPU y con un factor que se decide en tiempo de ejecución. Se compone de tres piezas: un shader que decide cuánto subdividir, una unidad de función fija que genera los puntos, y un shader que coloca cada punto generado en su sitio. Su caso de uso estrella es el detalle adaptativo: una montaña lejana se subdivide poco, la misma montaña cerca de la cámara se subdivide mucho, y el desplazamiento por textura de altura convierte la subdivisión en relieve real con silueta. Sin teselación, ese detalle hay que tenerlo ya en la malla, ocupando memoria aunque la cámara esté lejos.

El geometry shader recibe una primitiva completa y puede emitir cero, una o varias primitivas de salida, incluso de otro tipo. Con él se podía convertir cada punto en un cuadrilátero orientado hacia la cámara, extruir siluetas para volúmenes de sombra, dibujar normales como segmentos, o generar pelo a partir de triángulos. Es la etapa más flexible del pipeline clásico.

Las dos comparten una propiedad: amplifican. Una entrada produce un número variable de salidas. Y esa propiedad, que es justo lo que las hace útiles, es también lo que las condenó.

Por qué la amplificación rompe el modelo

Recuerda la restricción del pipeline: el paralelismo funciona porque cada etapa produce una cantidad de trabajo conocida y regular. Una invocación del vertex shader produce exactamente un vértice; el planificador puede repartir el trabajo sin pensar.

Una etapa que amplifica destruye esa propiedad en el peor sitio posible. Si cada invocación puede emitir entre cero y una decena de primitivas, el hardware tiene que reservar memoria para el peor caso, no para el caso medio; tiene que almacenar las salidas en algún sitio antes de pasarlas a la siguiente etapa; y —esto es lo grave— tiene que restaurar el orden, porque las primitivas deben llegar al rasterizador en el orden en que se enviaron. Un grupo de invocaciones que termina antes que otro no puede adelantarse.

El resultado, medido durante años en hardware real, es que el geometry shader rinde mucho peor de lo que su elegancia sugiere. En muchas GPU, una técnica implementada con geometry shader es más lenta que la misma técnica implementada con instancias, a pesar de hacer más trabajo aparente. En las arquitecturas móviles, basadas en teselado del framebuffer y con memoria interna limitada, el problema se agrava: el almacenamiento intermedio de primitivas amplificadas no encaja bien en un diseño pensado para minimizar el tráfico a memoria externa.

A eso se suma un hecho de portabilidad que decide la cuestión: Metal, la API nativa de Apple, no tiene geometry shaders. Una API web tiene que poder implementarse encima de Vulkan, Metal y Direct3D. Exigir una etapa que una de las tres no ofrece significaría emularla, y emular una etapa que ya es lenta de forma nativa da un resultado inaceptable.

Por eso ni WebGL2 —que se corresponde con un perfil de OpenGL ES que tampoco las tiene— ni WebGPU las exponen. WebGPU es explícito al respecto: se excluyeron por rendimiento y por portabilidad, no por falta de tiempo.

La industria no las ha sustituido por nada más pequeño, sino por algo más grande

La lectura fácil es que la web va con retraso y algún día llegarán. La realidad es la contraria: el hardware de escritorio también las está abandonando. La sustitución en marcha se llama pipeline de malla, donde los shaders de tarea y de malla reemplazan las etapas de vértice, teselación y geometría por un modelo de cómputo explícito: un grupo de hilos cooperativos decide cuánta geometría generar, la genera en memoria compartida y la emite en bloque, con el programador controlando el reparto en lugar de dejarlo a una unidad fija. Es más flexible y rinde mejor precisamente porque hace explícito lo que la teselación escondía. La consecuencia para quien programa en la web es liberadora: no estás esperando una funcionalidad que llegará, estás en el lado correcto de una transición. El modelo que hoy usas por obligación —generar geometría con cómputo y dibujarla con instancias— es el mismo que el escritorio está adoptando por elección. Aprender a amplificar geometría con cómputo no es un apaño mientras llega lo bueno; es lo bueno.

Las tres alternativas reales

Instanciación. Es la respuesta al noventa por ciento de los casos que motivaban un geometry shader. En lugar de generar un cuadrilátero por punto dentro de la GPU, defines un cuadrilátero una vez y lo dibujas diez mil veces con transformaciones distintas en una sola orden. Es más rápido que el geometry shader en prácticamente todo el hardware, y tiene su propio nivel en este track.

Generación aritmética en el vertex shader. El identificador de vértice permite calcular posiciones sin leer geometría. Si tu superficie es una función matemática —una malla regular, una espiral, una cuadrícula, un triángulo a pantalla completa— no necesitas ningún atributo: necesitas un contador y aritmética.

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
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.set(0, 3, 6);
camera.lookAt(0, 0, 0);

// Una rejilla de 128 por 128 puntos, sin ninguna posicion en memoria.
// Solo reservamos el numero de vertices que queremos dibujar.
const LADO = 128;
const total = LADO * LADO;
const geometria = new THREE.BufferGeometry();
geometria.setAttribute(
  'position',
  new THREE.BufferAttribute(new Float32Array(total * 3), 3)
);

const material = new THREE.ShaderMaterial({
  glslVersion: THREE.GLSL3,
  uniforms: { uTiempo: { value: 0 }, uLado: { value: LADO } },
  vertexShader: `
    uniform float uTiempo;
    uniform float uLado;
    out float vAltura;

    void main() {
      // La posicion se calcula a partir del identificador de vertice.
      float fila = floor(float(gl_VertexID) / uLado);
      float col  = mod(float(gl_VertexID), uLado);

      vec2 rejilla = (vec2(col, fila) / (uLado - 1.0) - 0.5) * 8.0;
      float altura = sin(rejilla.x * 1.5 + uTiempo) * cos(rejilla.y * 1.5 + uTiempo) * 0.6;

      vAltura = altura;
      vec4 mundo = modelViewMatrix * vec4(rejilla.x, altura, rejilla.y, 1.0);
      gl_Position = projectionMatrix * mundo;
      gl_PointSize = 3.0;
    }
  `,
  fragmentShader: `
    in float vAltura;
    out vec4 salida;

    void main() {
      vec3 color = mix(vec3(0.24, 0.33, 0.55), vec3(0.65, 0.89, 0.63), vAltura + 0.5);
      salida = vec4(color, 1.0);
    }
  `,
});

const puntos = new THREE.Points(geometria, material);
scene.add(puntos);

renderer.setAnimationLoop((tiempo) => {
  material.uniforms.uTiempo.value = tiempo / 1000;
  renderer.render(scene, camera);
});

El atributo position de ese ejemplo existe solo porque el renderer necesita saber cuántos vértices dibujar; sus valores no se leen nunca. Dieciséis mil puntos animados sin transferir un solo dato por fotograma. Y una precisión sobre glslVersion: THREE.GLSL3, porque casi todos los tutoriales la cuentan mal: un ShaderMaterial se compila como GLSL ES 3.00 siempre, lo pidas o no, así que gl_VertexID y las declaraciones in y out ya estaban disponibles sin tocar nada. Lo único que obliga a declarar la versión aquí es la línea out vec4 salida; del fragment shader, porque si no Three.js declara su propia salida en la misma posición y las dos chocan.

Cómputo en GPU. Cuando la geometría no es una fórmula sino el resultado de una simulación —partículas con física, hierba que reacciona al viento, un fluido— se calcula en un compute shader y se escribe en un búfer que luego se dibuja. Es la vía de WebGPU, y es exactamente el modelo hacia el que se mueve el hardware.

Qué hacer cuando querías teselación

El caso de la teselación merece una respuesta aparte, porque su motivación —detalle adaptativo con desplazamiento— no la cubre bien la instanciación.

En la web se resuelve de tres formas, y ninguna es un truco: son las mismas que usa buena parte de la industria. La primera es subdividir en la CPU una sola vez y guardar varias versiones de la malla, eligiendo cuál dibujar según la distancia; es el enfoque clásico de niveles de detalle y sigue siendo el más predecible. La segunda es desplazar en el vertex shader una malla ya suficientemente densa: no adapta la densidad a la distancia, pero es barato y el resultado con una malla razonable es indistinguible en la mayoría de escenas web. La tercera es fingir el relieve sin geometría, con mapas de normales o técnicas de paralaje: no cambia la silueta, pero el ojo perdona mucho más de lo que uno espera y el coste es una lectura de textura.

La regla práctica, después de haber intentado las tres: si la silueta importa —una cresta contra el cielo, un objeto que se recorta contra un fondo claro— necesitas geometría real. Si el relieve es interior a la superficie, un mapa de normales gana siempre, porque cuesta una fracción y no toca el pipeline de vértices.