1 puntos por GN⁺ 2024-11-11 | 1 comentarios | Compartir por WhatsApp
  • 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 uvec4 de 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_t en 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 \0 con discard y 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
  • imgui y stb_truetype tambié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 xy del 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 uvec4 de 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 menos 0x20
    • Se apunta a los glifos ASCII imprimibles que comienzan en 0x20 SPACE
    • En el código de ejemplo solo se muestran algunos elementos para ahorrar espacio
  • 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 char extraído puede editarse como un arreglo de uint y luego agruparse en unidades uvec4
  • Si se concatenan los char raw directamente como uint, 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 vec3 y uint dentro de un mismo binding
  • El texto se empaqueta en uint32_t en 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_data
    • pos_and_scale[3]: posición xy y escala
    • word: los cuatro caracteres a mostrar
  • La aplicación divide el mensaje en chunks de 4 caracteres, convierte cada chunk a uint32_t y los acumula en un arreglo word_data junto 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, el word a renderizar y el valor correspondiente a las coordenadas de textura
  • gl_Position usa los datos per-instance pos_and_scale para 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 uint al fragment shader
    • Se usa el qualifier flat para evitar interpolación
  • Las coordenadas de textura se sintetizan con gl_VertexIndex
    • 12 >> gl_VertexIndex & 1 genera la secuencia 0, 0, 1, 1
    • 9 >> gl_VertexIndex & 1 genera la secuencia 1, 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) hasta vec2(1.f,1.f), mientras que las coordenadas de píxel de glifo van desde uvec2(0,0) hasta uvec2(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 floor a uv.xy * vec2(8 * WORD_LEN, 16)
    • El rango de coordenadas se limita a uvec2(0..31, 0..15)
    • Con word_pixel_coord.x / 8 se determina a qué área de carácter pertenece dentro de los cuatro caracteres
    • Con word_pixel_coord.x % 8 se obtiene la coordenada x interna del glifo
  • El código del carácter se convierte en un índice de font_data
    • Como el primer glifo es 0x20 SPACE, se usa printable_character - 0x20 como offset
    • Con ese offset se obtiene el bitmap del glifo en uvec4
  • La coordenada y elige un uint específico dentro del uvec4 mediante glyph_pixel_coord.y / 4
    • Este uint contiene los datos de píxeles de 4 filas
    • Como los char extraídos desde ImHex se concatenaron tal cual para formar uint, el orden de las filas está invertido
    • Se corrige indexando desde atrás con (8*(3-(glyph_pixel_coord.y)%4))
  • 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 aplica mix(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 ejecuta discard
  • 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::DebugPrint para 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

 
GN⁺ 2024-11-11
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

    • Cada vez que intenté hacer renderizado de texto en modo inmediato, nunca logré que el texto se viera bien en tamaños pequeños. Incluso en el primer ShaderToy, si cambias el 30 de 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.
    • Hago desarrollo de juegos en Unity, y en este tema realmente se siente como ir hacia atrás. Hace unos años, Unity compró la mejor herramienta de renderizado de fuentes, creada por una sola persona, la incorporó como funcionalidad por defecto, y desde entonces el desarrollo prácticamente se detuvo, mientras que el mercado de renderizado de fuentes competidor también murió.
      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.
    • Como ejemplo interesante aunque fuera de tema, un efecto Matrix hecho con HTML/CSS/JS cabe en 1024 bytes: https://codegolf.stackexchange.com/a/17414
  • 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.

    • Siguiendo con lo de “volver al punto de partida”, si todavía no lo viste, vale la pena leer el paper de 1968 On the Design of Display Processors: http://cva.stanford.edu/classes/cs99s/papers/myer-sutherland...
      Ese paper habla de hardware, pero el software también tiene reencarnaciones.
    • Métodos como texto SDF o MSDF ya tienen al menos una generación de antigüedad. Hoy casi todos usan enfoques como https://sluglibrary.com, que rasterizan directamente las curvas Bézier de la fuente en el shader.
      Hace tiempo hice una versión muy básica de este concepto: https://www.shadertoy.com/view/sdXBDs
    • Por ejemplo, es bastante ingenioso para texto de depuración en situaciones donde la carga de texturas no funciona bien. Dicho eso, la comparación que hace el texto original entre una hoja de sprites y la composición tipográfica manual del siglo XVI es simpática, pero en la práctica a un asistente de imprenta le tomaba una hora componer tipos metálicos, mientras que subir una hoja de sprites a la GPU toma menos de 10 ms y luego se puede configurar indefinidamente.
      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/...

    • Yendo más allá, también es posible evaluar directamente las curvas de la fuente en la GPU, logrando alta calidad sin importar la escala o la perspectiva. Implementarlo de forma eficiente es muy difícil, pero es posible.
      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”.

    • La respuesta que yo quisiera a “¿qué tal el rendimiento?” es: “VSCode deja de comerse de cientos de MB a varios GB de mi VRAM”.
  • 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 snprintf a 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_FragCoord en 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...

    • Mi paquete de fuentes pixeladas favorito es este: https://int10h.org/oldschool-pc-fonts/
    • Hace poco me enteré de que la fuente “NES” viene del juego arcade Quiz Show de 1976. Esa fuente también se usó en otros juegos Kee/Atari en blanco y negro, y los datos de la fuente están en el set de ROM de quizshow para MAME, aunque por algún motivo están divididos en nibbles.
      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.

    • Esa idea está bastante desactualizada. En las GPU de los últimos 15 años más o menos, una consulta de textura es aproximadamente 100 veces más lenta que las operaciones de bits.
  • 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.

    • No es caro. Esta técnica apunta más a mostrar texto de depuración en pantalla de la manera más fácil posible que al rendimiento. Agregas un poco de datos al shader y ya aparece texto.
      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.
    • Incluso hoy suele ser buena idea no desperdiciar demasiada memoria de GPU. Una textura grande de caja de texto tiene que transferirse por el bus PCI, y puede provocar pausas según el momento de la subida o si se expulsan recursos de la GPU.
      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.
    • Dibujar un solo rectángulo que cubra N caracteres y hacer que el shader seleccione los glifos probablemente será más rápido que dibujar un rectángulo por cada carácter. Al menos con una fuente monoespaciada. Pero como hay un límite en la cantidad de caracteres que pueden llenar la pantalla, es probable que la diferencia real no sea grande.
      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.
    • Puedes renderizar toda la cadena antes de subirla, pero entonces esencialmente estás usando renderizado por CPU, así que será más lento que hacer que la GPU haga lo mismo.
      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.
    • Depende de la aplicación. Es el enfoque más fácil sobre todo si puedes encontrarte con texto de derecha a izquierda, CJK o emojis. La mayoría del texto no cambia en cada frame, así que vale la pena cachear la textura, y para nosotros es suficientemente bueno.
  • 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.

    • No, lo que dijo es que no va a guardar el bitmap en una textura. No es lo mismo que incrustarlo directamente en el código del 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.