Renderizar texto sin texturas
(poniesandlight.co.uk)- En situaciones donde se necesita imprimir rápidamente mensajes de depuración dentro de un render pass, preparar un atlas de fuentes puede ser una carga, por lo que resulta útil dibujar texto solo con constantes del fragment shader
- Los glifos se representan como bitmaps de 8x16 píxeles, y los 96 caracteres ASCII imprimibles se guardan en un arreglo
uvec4de 1536 bytes para consultarlos directamente desde el shader - Los datos de la fuente se pueden crear a partir de una fuente de terminal PSF1, saltando el header de 4 bytes y la sección de 512 bytes de glifos no imprimibles, y luego extrayendo los siguientes 96 glifos
- El renderizado usa
uint32_ten unidades de 4 caracteres y una estructura per-instance con posición y escala, para dibujar todo el texto con un único instanced draw call - Como es una técnica enfocada en salida simple de depuración, restricciones como el padding de 4 caracteres, el carácter de relleno
\0condiscardy la corrección de endianness deben manejarse conjuntamente en la aplicación y en el shader
Usar constantes del shader en lugar de un atlas de fuentes
- El renderizado de texto convencional consiste en renderizar los glifos disponibles en un atlas de fuentes, enlazarlo como textura y luego dibujar los triángulos correspondientes a cada glifo
imguiystb_truetypetambién usan este enfoque, pero para mostrar rápidamente mensajes de depuración el proceso de preparación puede resultar engorroso- Una alternativa es almacenar los datos equivalentes al atlas de fuentes como constantes enteras dentro del fragment shader
- Los enteros pueden usarse como bitmaps: se mapea la posición
xydel fragment a una posición de bit específica y, si ese bit está encendido, se emite el color de primer plano; si está apagado, el color de fondo
Glifos bitmap de 8x16 y tabla ASCII
- Como un byte solo representa una fila de píxeles, se usan 16 bytes por glifo para crear glifos más legibles
- Cada glifo queda como un lienzo de 8x16 píxeles, y un solo
uvec4de GLSL puede contener exactamente los 16 bytes necesarios - Al almacenar 96 glifos ASCII imprimibles, el total de datos es de 1536 bytes
- El arreglo
font_data[96]usa como índice el valor ASCII menos0x20- Se apunta a los glifos ASCII imprimibles que comienzan en
0x20SPACE - En el código de ejemplo solo se muestran algunos elementos para ahorrar espacio
- Se apunta a los glifos ASCII imprimibles que comienzan en
- La tabla bitmap completa está incluida en el código fuente de Island
Extraer el bitmap desde una fuente PSF1
- La codificación bitmap necesaria coincide casi exactamente con las fuentes de terminal en formato PSF1
- El procedimiento para extraer los datos de una fuente de terminal PSF1 es simple
- Abrir el archivo de fuente con un editor hexadecimal como ImHex
- Saltar el header de 4 bytes
- Saltar la sección de 512 bytes de glifos no imprimibles
- Exportar los siguientes 96 glifos, es decir, 1536 bytes, con “Copy as → C Array”
- El arreglo de
charextraído puede editarse como un arreglo deuinty luego agruparse en unidadesuvec4 - Si se concatenan los
charraw directamente comouint, el endianness queda invertido, por lo que se corrige de nuevo al muestrear - Los datos originales de la fuente de píxeles usada provienen de Tamsyn, una fuente de píxeles gratuita de Scott Fial
Composición de un único instanced draw call
- El renderizado de texto se procesa con un solo instanced draw call
- El draw call usa dos attribute streams
- El stream per-draw solo contiene la información necesaria para dibujar un quad común
- El stream per-instance contiene el offset de posición en pantalla y el texto que se va a mostrar
- Para el offset de posición se usan floats x, y, y en el espacio de float sobrante se puede poner el valor de escala de la fuente
- En Vulkan, todos los componentes de un vertex output binding deben tener las mismas características de interpolación, por lo que es difícil mezclar limpiamente
vec3yuintdentro de un mismo binding - El texto se empaqueta en
uint32_ten unidades de 4 caracteres- Como la unidad mínima del tipo de dato de un vertex attribute básico suele ser de 32 bits, se guardan 4 caracteres a la vez
- La longitud del mensaje debe ser divisible por 4
- Las partes faltantes se rellenan con el carácter
\0
- Los datos per-instance se representan con la estructura
word_datapos_and_scale[3]: posiciónxyy escalaword: los cuatro caracteres a mostrar
- La aplicación divide el mensaje en chunks de 4 caracteres, convierte cada chunk a
uint32_ty los acumula en un arregloword_datajunto con el offset de posición - Al renderizar, este arreglo se enlaza como el per-instance binding del pipeline de dibujo de texto de depuración, y se dibujan tantas instancias como quads
Pasar posición y caracteres desde el Vertex Shader
- El vertex shader emite
gl_Position, elworda renderizar y el valor correspondiente a las coordenadas de textura gl_Positionusa los datos per-instancepos_and_scalepara colocar los vértices de los triángulos en pantalla dentro del sistema de coordenadas NDC- El word a renderizar se pasa tal cual desde el attribute de entrada
uintal fragment shader- Se usa el qualifier
flatpara evitar interpolación
- Se usa el qualifier
- Las coordenadas de textura se sintetizan con
gl_VertexIndex12 >> gl_VertexIndex & 1genera la secuencia0, 0, 1, 19 >> gl_VertexIndex & 1genera la secuencia1, 0, 0, 1- Con esta combinación se generan las coordenadas uv
(0,1), (0,0), (1,0), (1,1)sin ramas
- El vertex shader también recibe los colores de primer plano y de fondo como datos per-instance y los pasa al fragment shader
Muestreo de glifos en el Fragment Shader
- Para renderizar el texto, el fragment shader necesita tres datos
- Las coordenadas uv interpoladas del fragment
- Los datos de caracteres a mostrar
in_word - El arreglo bitmap de glifos
font_data
- Las coordenadas uv van en el rango normalizado de float desde
vec2(0.f,0.f)hastavec2(1.f,1.f), mientras que las coordenadas de píxel de glifo van desdeuvec2(0,0)hastauvec2(7,15) - El word completo de 4 caracteres se trata como un área de 32 píxeles de ancho por 16 píxeles de alto
- Se cuantiza a coordenadas de píxel del word aplicando
floorauv.xy * vec2(8 * WORD_LEN, 16) - El rango de coordenadas se limita a
uvec2(0..31, 0..15) - Con
word_pixel_coord.x / 8se determina a qué área de carácter pertenece dentro de los cuatro caracteres - Con
word_pixel_coord.x % 8se obtiene la coordenada x interna del glifo
- Se cuantiza a coordenadas de píxel del word aplicando
- El código del carácter se convierte en un índice de
font_data- Como el primer glifo es
0x20SPACE, se usaprintable_character - 0x20como offset - Con ese offset se obtiene el bitmap del glifo en
uvec4
- Como el primer glifo es
- La coordenada y elige un
uintespecífico dentro deluvec4medianteglyph_pixel_coord.y / 4- Este
uintcontiene los datos de píxeles de 4 filas - Como los
charextraídos desde ImHex se concatenaron tal cual para formaruint, el orden de las filas está invertido - Se corrige indexando desde atrás con
(8*(3-(glyph_pixel_coord.y)%4))
- Este
- La coordenada x selecciona el bit con
7-glyph_pixel_coord.x- Como el bit más significativo del byte está almacenado en el índice más alto, se necesita indexado inverso para corresponder de izquierda a derecha
- Con el valor final
current_pixel, se aplicamix(background_colour, foreground_colour, current_pixel)para definir el color
Manejo de cadenas cortas y caracteres de relleno
- Si la longitud de la cadena no es divisible por 4, la aplicación rellena las partes faltantes con el carácter
\0 - El fragment shader comprueba si el carácter que se quiere mostrar es
\0 - Al encontrar un carácter de relleno
\0, no dibuja ni siquiera el fondo y ejecutadiscard - Con este manejo se pueden mostrar cadenas cortas sin dejar de mantener la restricción de empaquetado de 4 caracteres
Forma de uso y ubicación del código
- En el proyecto Island se puede llamar a
le::DebugPrintpara mostrar texto de depuración en pantalla - El código completo del fragment shader está disponible en github
- El ejemplo de código pasa datos de cadena para mostrar en pantalla un mensaje con el formato
"That's all, %s"
1 comentarios
Opiniones en Hacker News
Si quieres probarlo tú mismo, basta con seguir las cuentas. Es facilísimo implementarlo desde cero en ShaderToy, y si te gustan estas cosas, también es una actividad divertida para un sábado por la mañana.
Hacerlo desde cero es divertido, pero si necesitas una pista para empezar, aquí hay un ejemplo que acabo de hacer: https://www.shadertoy.com/view/Mc3cW2
También hay muchos hacks ingeniosos de texto hechos por otras personas, como un ejemplo de Matrix de menos de 300 caracteres https://www.shadertoy.com/view/llXSzj o un efecto de pantalla CRT verde https://www.shadertoy.com/view/XtfSD8
vec2(30, -30)a 300, se ven artefactos.Me pregunto si hay algún truco para manejarlo bien. En mi caso, lo que mejor funcionó fue multimuestrear la textura dentro del fragment shader, pero aun así no quedaba tan bien como lo moderno.
Hace poco intenté crear una app que se viera como una fuente de consola nativa, y tuve que estar más de 2 horas ajustando cosas solo para llegar a un 90% de aproximación.
Es ingenioso, hacky y divertido. En realidad, casi todas las técnicas de renderizado 3D lo son, pero el resultado no es particularmente bello salvo que estés intentando recrear los viejos BBS.
Podrías mejorarlo agregando más bits, pero mucho antes de que empiece a verse bien terminarás buscando una forma más sencilla de configurar todos esos bits. Al final vuelves al punto de partida, porque casi no habrá una solución más eficiente que hacerlo con píxeles en blanco y negro en un programa de dibujo y guardarlo como textura.
Si te interesa la forma más común en que los motores modernos de renderizado 3D dibujan texto, busca texto SDF y técnicas relacionadas como MSDF. Es un método que usa, en una etapa de preprocesamiento, un atlas de texturas tradicional para crear un atlas de campos de distancia con signo.
Ese paper habla de hardware, pero el software también tiene reencarnaciones.
Hace tiempo hice una versión muy básica de este concepto: https://www.shadertoy.com/view/sdXBDs
No quiero decir que no sea un truco genial; de hecho, es un truco genial.
También existe la opción de renderizar el texto como malla. TextMeshPro va un paso más allá y usa campos de distancia con signo para manejar escalas arbitrarias.
https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...
Ejemplo: https://sluglibrary.com
Las mallas y SDF son mucho más simples del lado de la GPU, pero si se amplían demasiado pueden perder precisión, y si una malla se reduce demasiado puede aparecer aliasing.
Muy bueno. Sería interesante ver una comparación de rendimiento con el método “tradicional” basado en texturas.
En las GPU actuales, cuando se trata de tareas simples como esta, la respuesta a “¿qué tal el rendimiento?” suele ser “funciona”.
Sebastian Lague hizo un buen video que cubre varias técnicas de renderizado de fuentes.
https://youtu.be/SO83KQuuZvg
He probado una técnica parecida: metí todos los datos de la fuente directamente en el código fuente del fragment shader. Así podía imprimir directamente con
snprintfa un búfer de GPU mapeado en la CPU. Sé que es una forma peligrosa de hacerlo.En vez de dibujar cada carácter con el vertex shader, dibujaba un solo triángulo de pantalla completa y usaba
gl_FragCoorden lugar de coordenadas UV. No es el método más eficiente, pero era una función de depuración y, en la práctica, era lo bastante rápido.A pesar del nombre del archivo, no usa una fuente de NES sino la de la ROM de IBM PC. La “fuente NES” y otras fuentes de 8x8 píxeles se pueden encontrar en la web.
https://github.com/rikusalminen/triangles/blob/nesfont/shade...
Curiosamente, este juego almacenaba los datos de preguntas y respuestas en cintas de 8 pistas.
Genial. No es frecuente ver un algoritmo de renderizado de texto que no haya intentado yo mismo. En mi startup implementamos varios, pero yo necesitaba independencia de resolución y antialiasing, así que este enfoque no me habría servido.
Puede que no se generalice a todos los archivos de fuentes con curvas Bézier. Convertir curvas a píxeles puede ser difícil, especialmente cuando un glifo se cruza consigo mismo. En general, el renderizado de texto estándar se siente como un problema resuelto, y los casos de uso no estándar son muy duros de intentar.
Este enfoque conceptualmente se parece a un método de Will Dobbie que me gusta, solo que es mucho más simple. Ambos toman datos de fuente sin procesar y los usan directamente en el shader. La diferencia es que este método guarda los datos de píxeles en un arreglo, mientras que Will guarda datos de rutas SVG como una “textura vectorial”.
Si te da curiosidad, Will tiene una demo excelente: https://wdobbie.com/warandpeace/
Hace tiempo pensé en probar algo así, pero tenía entendido que la GPU es especialmente eficiente para renderizar texturas y relativamente lenta para la manipulación de bits. Aunque aquí se ahorre algo de memoria, me pregunto si en la práctica será más rápido que usar un atlas.
Quizá se podría obtener lo mejor de ambos mundos empaquetando bits en una textura normal y haciendo que el fragment shader los decodifique.
Pregunto desde la ignorancia casi total sobre gráficos por computadora modernos: ¿tan alto es el costo de subir una textura pequeña a la GPU? Me pregunto si no se podría renderizar toda la cadena en una textura 2D y luego mostrar esa textura en dos triángulos.
En cambio, tendrías que escribir código para generar un atlas de fuentes, o buscar y cargar un atlas existente, y para eso también necesitas código de carga. O tendrías que dibujar todo el mensaje en una textura y cachear el resultado hasta que el mensaje cambie.
Además hay que hacer gestión de recursos y bindings, mientras que este método no necesita recursos. En otras palabras, no es una solución general para texto, sino una técnica para mostrar texto de depuración en pantalla.
Como referencia, la mayoría de los navegadores y sistemas operativos funcionan dibujando texto en texturas. Dibujan dinámicamente fuentes en un atlas de texturas y usan los glifos de ese atlas para crear más texturas para partes de la ventana de la app. Si muestras los bordes de texturas en un navegador puedes verlas todas, y Rendering->Layer borders marca cada textura con un borde color cian.
Si una CPU relativamente lenta termina renderizando muchas cajas de texto independientes como texturas, eso se acumula rápido y se come el presupuesto.
Dibujar usando un atlas de glifos sigue siendo mucho mejor en uso de recursos. Las canalizaciones modernas de renderizado de texto suelen usar SDF o curvas Bézier codificadas para mejorar la legibilidad de los glifos al hacer zoom, y eso también es una buena forma de ahorrar memoria.
Desde el punto de vista de la subida, al final hay X bytes de glifos y de alguna forma tienen que estar en la memoria de la GPU. Que sea una textura, datos uniform o constantes del shader no cambia mucho el rendimiento. De hecho, si los metes como constantes del shader, como en el texto original, el costo del lado de la CPU podría ser mayor porque el compilador de shaders tiene que procesar todas esas declaraciones de constantes.
En la GPU, lo importante es qué jerarquía de memoria se toca al leer los datos de glifos. Las lecturas de textura usan una caché L1 dedicada en la mayoría de las GPU, y probablemente sea más grande que la caché L1 general. El orden de los datos también importa. Las texturas normalmente se almacenan en una variante de orden Morton para evitar fallos de caché al sombrear bloques de píxeles. Para un renderizador de texto basado en atlas en uso real, convendría usar texturas.
Edit: leí mal la pregunta. Si la comparación era entre dibujar glifos individuales en la GPU y dibujar todo el bloque de texto en la CPU, entonces es un compromiso entre velocidad y espacio. La respuesta depende de cuánta memoria quieras usar para el texto, si el texto cambia, si necesitas efectos por carácter, etc.
Además, aunque lo llamen “sin texturas”, este método también es una textura. Solo que la textura está almacenada en otro formato y en otra ubicación. El renderizado de fuentes verdaderamente sin textura evalúa curvas vectoriales en el momento.
Resulta bastante confuso que diga que no va a guardar un bitmap en el shader y luego explique exactamente cómo guardar un bitmap en el shader
En resumen, es incrustar una fuente bitmap en el shader.
Se puede comparar con la diferencia entre guardar datos en un archivo separado que debe leerse en tiempo de ejecución e incluir los datos directamente en el código fuente.