wandres.dev
TEXTO EN 3D · Geometría, sprites y SDF

troika-three-text y los campos de distancia con signo

Por qué un SDF mantiene el borde nítido a cualquier escala, cómo troika genera el atlas en un worker desde la fuente real, y la API completa que necesitas.

⏱ 20 min

Un mapa de bits interpolado se emborrona al ampliarlo porque lo que se interpola es la cobertura del píxel. Un campo de distancia interpolado no se emborrona porque lo que se interpola es la distancia al borde, y la interpolación lineal de una distancia sigue siendo, aproximadamente, una distancia. Ese cambio de qué se almacena es la idea entera, tiene diecinueve años, y es lo que permite que una letra de sesenta y cuatro téxeles se vea perfecta ocupando media pantalla.

🎯 Al terminar esta lección sabrás
  • Explicar por qué un SDF conserva el filo del borde bajo magnificación y un mapa de cobertura no.
  • Reconocer el artefacto característico del SDF de un solo canal y cuándo importa.
  • Usar la API de troika-three-text completa, incluyendo la precarga y el ciclo asíncrono.
  • Situar el coste de troika frente a las otras técnicas de texto.

Qué se guarda en un campo de distancia

En una textura de glifo convencional, cada téxel guarda cuánta tinta hay: cerca de uno dentro de la letra, cerca de cero fuera, y valores intermedios en el borde. Al ampliar, el filtrado bilineal interpola cobertura, y una rampa de cobertura ampliada es una rampa más ancha. Eso es exactamente lo que percibimos como desenfoque.

En un campo de distancia con signo, cada téxel guarda la distancia al contorno más cercano, negativa dentro y positiva fuera, normalizada a un rango. El contorno de la letra es el conjunto de puntos donde ese campo vale cero. Al ampliar, el filtrado bilineal interpola distancias, y la interpolación de un campo de distancias sigue siendo aproximadamente un campo de distancias: la superficie de nivel cero sigue estando exactamente en el mismo sitio geométrico. El borde no se ensancha, se reconstruye.

Y como el campo es continuo, el antialiasing sale gratis y con la anchura correcta: basta con hacer un smoothstep de un píxel de ancho alrededor del cero. La técnica es de Chris Green, en un artículo de Valve de 2007, y desde entonces es como se dibuja el texto en prácticamente todos los motores de videojuego.

// La idea, en tres lineas
float d = texture2D( uSdf, vUv ).r - 0.5;   // 0.5 es el cero del campo codificado
float w = fwidth( d );                       // el ancho de un pixel en unidades de campo
float alfa = smoothstep( -w, w, -d );        // dentro es alfa 1

Sin fwidth la anchura del suavizado sería fija en unidades de textura y por tanto variable en píxeles, con lo que el texto lejano quedaría duro y el cercano blando. Con fwidth la transición mide siempre lo mismo en pantalla, que es lo que hace que la técnica funcione a cualquier escala.

El defecto del canal único, y por qué troika lo tolera

Un SDF de un solo canal no puede representar dos bordes que se cruzan dentro de un mismo téxel. En una esquina viva —el vértice de una “A”, la punta de una “W”— el téxel de la esquina almacena una única distancia y la reconstrucción redondea el pico. A tamaños normales no se ve; a tamaños muy grandes, sí.

La solución conocida es el MSDF, campo de distancia multicanal: se guardan tres distancias en R, G y B, correspondientes a distintos subconjuntos de aristas, y la mediana de las tres reconstruye las esquinas exactas. Es lo que hace msdfgen y lo que usan librerías como three-msdf-text.

troika-three-text usa SDF de canal único y compensa por otra vía: en vez de una única textura ampliada uniformemente, genera el campo por glifo con una resolución configurable y aplica el antialiasing con derivadas en el fragment shader. El resultado es excelente hasta tamaños grandes y solo se queda corto en tipografía de titular gigante con serifas finas. A cambio, el atlas ocupa un tercio y la generación es mucho más rápida, lo que importa porque troika genera el atlas en tiempo de ejecución.

Ese es el punto que separa a troika del resto: no consume un atlas precocinado, sino que lee el fichero de fuente real —WOFF, TTF u OTF— con un parser propio, extrae los contornos de los glifos que tu texto necesita, y calcula sus campos de distancia en un web worker. Nunca subes al servidor una imagen de atlas, ni tienes que decidir de antemano qué caracteres vas a usar, ni te quedas sin poder escribir un carácter que no previste.

La API que necesitas

import { Text, preloadFont } from 'troika-three-text';

// 1. Precarga opcional pero muy recomendable: evita el parpadeo del primer sync
await new Promise( ( resolver ) => {
  preloadFont(
    {
      font: '/fonts/Inter-SemiBold.woff',
      characters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz 0123456789.,',
      sdfGlyphSize: 64,
    },
    resolver
  );
} );

// 2. Crear y configurar
const rotulo = new Text();
rotulo.text = 'Campos de distancia';
rotulo.font = '/fonts/Inter-SemiBold.woff';
rotulo.fontSize = 0.35;
rotulo.color = 0xcdd6f4;
rotulo.anchorX = 'center';
rotulo.anchorY = 'middle';
rotulo.maxWidth = 4;
rotulo.textAlign = 'center';
rotulo.lineHeight = 1.25;
rotulo.letterSpacing = 0.01;
rotulo.outlineWidth = '3%';
rotulo.outlineColor = 0x11111b;
rotulo.sdfGlyphSize = 64;

scene.add( rotulo );

// 3. sync() aplica los cambios. Es asincrono.
rotulo.sync( () => {
  // Aqui, y solo aqui, la geometria y el boundingSphere ya existen
  rotulo.geometry.computeBoundingBox();
} );

Propiedades que merece la pena conocer más allá de las obvias. curveRadius curva el texto sobre un cilindro virtual, ideal para etiquetas alrededor de un objeto. clipRect recorta el texto a un rectángulo en el espacio local, útil para listas con scroll. fillOpacity, strokeWidth y strokeColor dan trazo interior sin tocar el contorno exterior. depthOffset desplaza la profundidad para resolver el z-fighting cuando el texto está pegado a una superficie.

Y la que casi nadie usa: el material es reemplazable. Troika parchea cualquier material de Three.js para aplicar el recorte por SDF, así que puedes darle iluminación PBR completa.

rotulo.material = new THREE.MeshStandardMaterial( {
  roughness: 0.4,
  metalness: 0.8,
} );
rotulo.sync();

El coste de dibujado es sobresaliente: cada instancia de Text es una sola draw call, con la geometría instanciada —un cuadrado por glifo— y el atlas compartido entre todas las instancias que usen la misma fuente. Cien etiquetas con la misma tipografía son cien draw calls pero una sola textura, frente a las cien texturas de los sprites de canvas.

La limpieza también es una sola llamada:

rotulo.removeFromParent();
rotulo.dispose();

Dónde troika no llega

No renderiza escrituras que requieran reordenación compleja con la misma fidelidad que el motor del navegador; para latín, cirílico, griego y la mayoría de escrituras alfabéticas va sobrado, pero si tu producto tiene que servir devanagari o thai correctamente, el canvas 2D del navegador sigue siendo la única opción que garantiza el resultado.

No es texto real para el usuario: no se puede seleccionar con el ratón ni lo lee un lector de pantalla. Troika ofrece getSelectionRects y getCaretAtPoint para construir selección propia, pero eso es implementar un editor de texto, no obtener uno.

Y el primer sync de una fuente nueva tiene un coste medible: parsear el WOFF y generar los SDF de los glifos usados lleva entre decenas y cientos de milisegundos. Ocurre en un worker, así que no bloquea el hilo principal, pero el texto no aparece hasta que termina. De ahí la importancia de preloadFont.

sync es asincrono y todo lo que dependa de su geometria tiene que esperar

El fallo más frecuente con troika no tiene nada que ver con SDF: es tratar Text como si fuera un Mesh normal. Cuando asignas rotulo.text = '...' no ocurre nada; el objeto solo apunta el cambio. sync() encola el trabajo, que puede requerir cargar la fuente, parsearla y generar glifos en un worker, y devuelve inmediatamente. Durante ese intervalo la geometría del Text está vacía: geometry.boundingSphere es nulo o degenerado, textRenderInfo es null, y cualquier cálculo de disposición basado en el ancho del texto devuelve cero. El síntoma clásico es un panel de interfaz 3D cuyos elementos aparecen todos apilados en el origen durante el primer cuadro y saltan a su sitio en el segundo, o un raycast que no acierta nunca porque la esfera envolvente mide cero. La regla: todo lo que dependa de las dimensiones del texto va dentro del callback de sync(), y si tienes varios rótulos que se colocan unos respecto de otros, junta sus promesas antes de disponer nada. Un envoltorio que devuelva una promesa cuesta cuatro líneas y ahorra la clase entera de bugs: const sincronizar = ( t ) => new Promise( ( r ) => t.sync( r ) ); y después await Promise.all( rotulos.map( sincronizar ) );.