Sprites de texto: pintar en un canvas y pegarlo
La técnica más antigua y todavía la más práctica para etiquetas: dibujar texto con la API 2D, subirlo como textura y colocarlo en un Sprite que siempre mira a la cámara.
Un Sprite es un cuadrado que siempre encara a la cámara, y el navegador ya trae un motor de tipografía completo en el canvas 2D. Combinar las dos cosas da texto en 3D con kerning real, ligaduras reales, emoji, cualquier idioma y cero dependencias. Es la técnica que más se subestima, y su único defecto —la resolución fija— tiene remedios que casi nadie aplica.
- Construir una etiqueta de texto completa con
CanvasTextureySpriteMaterial. - Dimensionar el canvas para que el texto no se vea borroso en pantallas de alta densidad.
- Elegir entre tamaño atenuado por distancia y tamaño constante en pantalla.
- Liberar correctamente la textura y el material de una etiqueta.
El canvas hace el trabajo difícil
Todo lo que TextGeometry no sabe hacer, CanvasRenderingContext2D lo hace de fábrica: shaping de escrituras complejas, bidireccionalidad, ligaduras, kerning de la fuente real, variantes OpenType con fontVariantCaps, y por supuesto cualquier fuente instalada o cargada con @font-face. El navegador lleva veinte años puliendo ese motor.
La técnica consiste en dibujar el texto una vez en un canvas fuera del DOM, envolverlo en una CanvasTexture y usarlo como mapa de un SpriteMaterial. A partir de ahí el texto es una imagen y el coste por cuadro es el de un cuadrado con textura.
import * as THREE from 'three';
function crearEtiqueta( texto, opciones = {} ) {
const {
tamanoFuente = 64,
familia = 'system-ui, sans-serif',
peso = '600',
color = '#cdd6f4',
fondo = 'rgba(17, 17, 27, 0.75)',
margen = 18,
radio = 12,
escalaMundo = 0.008,
} = opciones;
const dpr = Math.min( window.devicePixelRatio || 1, 2 );
const fuente = `${peso} ${tamanoFuente}px ${familia}`;
// 1. Medir con un contexto desechable
const medidor = document.createElement( 'canvas' ).getContext( '2d' );
medidor.font = fuente;
const anchoTexto = medidor.measureText( texto ).width;
const ancho = Math.ceil( anchoTexto ) + margen * 2;
const alto = Math.ceil( tamanoFuente * 1.35 ) + margen * 2;
// 2. Dibujar a resolucion de dispositivo
const canvas = document.createElement( 'canvas' );
canvas.width = Math.ceil( ancho * dpr );
canvas.height = Math.ceil( alto * dpr );
const ctx = canvas.getContext( '2d' );
ctx.scale( dpr, dpr );
ctx.fillStyle = fondo;
ctx.beginPath();
ctx.roundRect( 0, 0, ancho, alto, radio );
ctx.fill();
ctx.font = fuente;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillStyle = color;
ctx.fillText( texto, ancho / 2, alto / 2 );
// 3. Convertir en textura
const textura = new THREE.CanvasTexture( canvas );
textura.colorSpace = THREE.SRGBColorSpace; // es color, no un valor numerico
textura.minFilter = THREE.LinearMipmapLinearFilter;
textura.magFilter = THREE.LinearFilter;
textura.generateMipmaps = true;
const material = new THREE.SpriteMaterial( {
map: textura,
transparent: true,
depthWrite: false,
} );
const sprite = new THREE.Sprite( material );
sprite.scale.set( ancho * escalaMundo, alto * escalaMundo, 1 );
return sprite;
}
const etiqueta = crearEtiqueta( 'Motor principal' );
etiqueta.position.set( 0, 1.4, 0 );
scene.add( etiqueta );
roundRect está disponible en los cuatro motores desde 2023; si necesitas cubrir navegadores más antiguos, un fillRect sirve igual.
Resolución: el único defecto real
Un sprite de texto es una imagen de tamaño fijo. Acércate lo suficiente y verás píxeles. La cuestión no es si ocurre sino a qué distancia, y eso lo decides tú con tres palancas.
La primera es el devicePixelRatio. Un canvas de 400 por 100 CSS píxeles en una pantalla con dpr de 2 necesita un búfer de 800 por 200. El código de arriba lo hace, y limita a 2 porque a partir de ahí el coste de memoria crece más rápido que la mejora visible.
La segunda es el tamanoFuente del canvas frente a la escala del sprite en el mundo. Dibujar a 64 píxeles y escalar el sprite para que ocupe 200 píxeles en pantalla significa aumentar tres veces, y ahí se ve. Dibujar a 128 y ocupar 200 no.
La tercera es la anisotropía. Un sprite visto en escorzo pronunciado —una etiqueta plantada sobre un suelo, mirada casi de canto— sufre exactamente el mismo aliasing que cualquier textura, y el remedio es el mismo:
textura.anisotropy = renderer.capabilities.getMaxAnisotropy();
Aunque en un Sprite puro, que siempre encara la cámara, el escorzo no llega a producirse. Importa cuando usas la misma técnica sobre un PlaneGeometry orientable en vez de un Sprite.
Atenuado o constante
SpriteMaterial.sizeAttenuation decide si el sprite encoge con la distancia. Vale true por defecto, que es el comportamiento de un objeto físico.
material.sizeAttenuation = false;
Con false, el sprite mantiene su tamaño en píxeles con independencia de la distancia, y sprite.scale pasa a interpretarse en unidades proporcionales a la altura del viewport. Ese es el modo de una etiqueta de interfaz: un nombre sobre un personaje que tiene que ser legible esté a dos metros o a cincuenta.
Un tercer modo, el más usado en visualización de datos, es intermedio: atenuar pero con un suelo, para que las etiquetas lejanas encojan pero nunca por debajo de lo legible.
const _mundo = new THREE.Vector3();
function actualizarEscala( sprite, camara, base, minimo = 0.35 ) {
sprite.getWorldPosition( _mundo );
const d = _mundo.distanceTo( camara.position );
const k = Math.max( minimo, Math.min( 1, 12 / d ) );
sprite.scale.set( base.x * k, base.y * k, 1 );
}
Otros dos ajustes que conviene conocer: sprite.center es un Vector2 con el punto de anclaje dentro del cuadrado, ( 0.5, 0.5 ) por defecto; ponerlo a ( 0.5, 0 ) hace que el sprite crezca hacia arriba desde su posición, que es lo que quieres para una etiqueta plantada sobre un objeto. Y material.rotation gira el sprite en el plano de la pantalla, en radianes.
Coste y limpieza
Cada etiqueta es un Sprite, y cada Sprite con su propio material es una draw call. Cincuenta etiquetas son cincuenta draw calls, más cincuenta texturas en memoria. Una etiqueta de 400 por 100 a dpr 2 ocupa 800 por 200 por 4 bytes, es decir 640 KB, y con mipmaps un tercio más. Cincuenta etiquetas se comen 42 MB de VRAM.
Ese es el techo real de la técnica, y el remedio pasa por un atlas: dibujar todas las etiquetas en un único canvas grande, generar una sola textura, y usar PlaneGeometry con UV recortadas en vez de Sprite. Se pierde el encarado automático, que hay que hacer a mano con mesh.quaternion.copy( camera.quaternion ), y se gana una única draw call.
La limpieza es obligatoria y tiene tres pasos, porque son tres recursos distintos:
function destruirEtiqueta( sprite ) {
sprite.removeFromParent();
sprite.material.map.dispose();
sprite.material.dispose();
}
Sprite comparte una geometría interna entre todas las instancias, así que esa no se toca.
ctx.fillText con una fuente que todavía no ha cargado no falla ni espera: dibuja con la sustituta que el sistema decida y devuelve el control como si nada. Peor aún, measureText también devuelve las métricas de la sustituta, así que el canvas queda dimensionado para una fuente y pintado con otra, y el texto sale recortado o descentrado. En desarrollo casi nunca lo ves, porque la fuente ya está en la caché del navegador desde la recarga anterior; en la primera visita de un usuario real, sí. La API que lo arregla es FontFaceSet y hay que usar la versión específica, no la global: await document.fonts.load( '600 64px Inter' ) carga esa combinación concreta de peso, tamaño y familia y resuelve cuando está disponible. await document.fonts.ready también sirve pero solo garantiza que han terminado las fuentes que el documento ya había pedido, y si la tuya solo se usa dentro del canvas el navegador nunca la pidió, así que ready resuelve inmediatamente sin haber cargado nada. La secuencia correcta es cargar explícitamente la combinación exacta, esperarla, y solo entonces medir y dibujar. Y si la etiqueta se puede regenerar, registra un document.fonts.addEventListener( 'loadingdone', ... ) para repintar las que se crearon antes de tiempo.