Representación de fuentes con máscaras de cobertura, parte 1

imagen

Cuando comenzamos a desarrollar nuestro perfilador de rendimiento , sabíamos que haríamos casi todo el procesamiento de la interfaz de usuario por nuestra cuenta. Pronto tuvimos que decidir qué enfoque elegir para renderizar fuentes. Teníamos los siguientes requisitos:

  1. Debemos poder representar cualquier fuente de cualquier tamaño en tiempo real para adaptarnos a las fuentes del sistema y los tamaños elegidos por los usuarios de Windows.
  2. El renderizado de fuentes debe ser muy rápido, sin frenado cuando se permite renderizar fuentes.
  3. Nuestra interfaz de usuario tiene un montón de animaciones suaves, por lo que el texto debe poder moverse suavemente por la pantalla.
  4. Debe ser legible con tamaños de fuente pequeños.

Al no ser un gran especialista en ese momento, busqué información en Internet y encontré muchas técnicas utilizadas para representar fuentes. También hablé con el director técnico de Guerrilla Games, Michail van der Leu. Esta compañía experimentó con muchas formas de renderizar fuentes, y su motor de renderizado fue uno de los mejores del mundo. Mihil describió brevemente su idea para una nueva técnica de representación de fuentes. Aunque ya hubiéramos tenido suficientes técnicas disponibles, esta idea me intrigó y comencé a implementarla, sin prestar atención al maravilloso mundo de la representación de fuentes que se me abrió.

En esta serie de artículos, describiré en detalle la técnica que usamos, dividiendo la descripción en tres partes:

  • En la primera parte, aprenderemos cómo renderizar glifos en tiempo real usando 16xAA, muestreados desde una cuadrícula uniforme.
  • En la segunda parte, pasaremos a la cuadrícula girada para realizar bellamente el suavizado de bordes horizontales y verticales. También veremos cómo el sombreador terminado se reduce casi por completo a una textura y una tabla de búsqueda.
  • En la tercera parte, aprenderemos cómo rasterizar glifos en tiempo real usando Compute y CPU.

También puede ver los resultados finales en el generador de perfiles, pero aquí hay un ejemplo de una pantalla con la fuente Segoe UI renderizada utilizando nuestro renderizador de fuentes:


Aquí hay un aumento en la letra S, un tamaño rasterizado de solo 6x9 texels. Los datos vectoriales originales se representan como una ruta y el patrón de muestra girado se representa a partir de rectángulos verdes y rojos. Dado que se procesa con una resolución muy superior a 6 × 9, los tonos de gris no se representan en el tono final de píxeles, muestra el tono del subpíxel. Esta es una visualización de depuración muy útil para asegurarse de que todos los cálculos en el nivel de subpíxeles funcionen correctamente.


Idea: almacenar recubrimiento en lugar de sombra


El principal problema con el que deben lidiar los renderizadores de fuentes es mostrar datos de fuentes vectoriales escalables en una cuadrícula de píxeles fija. El método de transición del espacio vectorial a píxeles terminados en diferentes técnicas es muy diferente. En la mayoría de estas técnicas, los datos de la curva se rasterizan antes de procesarlos en un almacenamiento temporal (por ejemplo, una textura) para obtener un tamaño específico en píxeles. El almacenamiento temporal se utiliza como caché de glifos: cuando el mismo glifo se representa varias veces, los glifos se toman del caché y se reutilizan para evitar la rasterización.

La diferencia en la técnica es claramente visible en cómo se almacenan los datos en un formato de datos intermedio. Por ejemplo, el sistema de fuentes de Windows rasteriza los glifos a un tamaño específico en píxeles. Los datos se almacenan como un tono por píxel. La sombra describe la mejor aproximación de la cobertura por el glifo de este píxel. Al renderizar, los píxeles simplemente se copian de la caché de glifos a la cuadrícula de píxeles de destino. Al convertir datos a un formato de píxeles, no se escalan bien, por lo tanto, al alejar, aparecen glifos difusos, y al acercar, aparecen glifos en los que los bloques son claramente visibles. Por lo tanto, para cada tamaño final, los glifos se representan en el caché de glifos.

Los campos distanciados firmados utilizan un enfoque diferente. En lugar de matiz para el píxel, se mantiene la distancia al borde más cercano del glifo. La ventaja de este método es que para los bordes curvos, los datos se escalan mucho mejor que las sombras. A medida que el glifo se acerca, las curvas permanecen suaves. La desventaja de este enfoque es que los bordes rectos y afilados se suavizan. Mucho mejor que SDF se logra con soluciones avanzadas como FreeType , que almacenan datos de color.

En los casos en que se retiene un tono para un píxel, primero debe calcular su cobertura. Por ejemplo, stb_truetype tiene buenos ejemplos de cómo puede calcular la cobertura y el tono. Otra forma popular de aproximar la cobertura es muestrear el glifo a una frecuencia más alta que la resolución final. Esto cuenta el número de muestras que se ajustan al glifo en el área de píxeles objetivo. El número de visitas dividido por el número máximo de muestras posibles determina el tono. Dado que la cobertura ya se ha convertido en un tono para una resolución y alineación de cuadrícula de píxeles específica, es imposible colocar glifos entre los píxeles de destino: el tono no puede reflejar correctamente la cobertura real con muestras de la ventana de píxeles de destino. Por esto, así como por algunas otras razones que consideraremos más adelante, dichos sistemas no admiten el movimiento de subpíxeles.

Pero, ¿qué pasa si necesitamos mover libremente el glifo entre los píxeles? Si el tono se calcula por adelantado, no podemos averiguar cuál debería ser el tono cuando se mueve entre píxeles en el área de píxeles objetivo. Sin embargo, podemos retrasar la conversión de cobertura a matiz en el momento del renderizado. Para hacer esto, no almacenaremos la sombra, sino el revestimiento . Muestramos un glifo con una frecuencia de resolución objetivo de 16, y para cada muestra guardamos un solo bit. Al muestrear en una cuadrícula de 4 × 4, es suficiente almacenar solo 16 bits por píxel. Esta será nuestra máscara de tapa . Durante el renderizado, necesitamos contar cuántos bits entran en la ventana de píxeles de destino, que tiene la misma resolución que el repositorio de texel, pero no está físicamente adjunto. La siguiente animación muestra una parte del glifo (azul) rasterizado en cuatro texels. Cada texel se divide en una cuadrícula de 4 × 4 celdas. Un rectángulo gris indica una ventana de píxeles que se mueve dinámicamente a través del glifo. En tiempo de ejecución, se cuenta el número de muestras que caen en la ventana de píxeles para determinar el tono.


Brevemente sobre las técnicas básicas de representación de fuentes


Antes de pasar a discutir la implementación de nuestro sistema de representación de fuentes, quiero hablar brevemente sobre las principales técnicas utilizadas en este proceso: sugerencia de fuentes y representación de subpíxeles (esta técnica se llama ClearType en Windows). Puede omitir esta sección si solo está interesado en las técnicas de suavizado.

En el proceso de implementación del renderizador, aprendí cada vez más sobre la larga historia del desarrollo del renderizado de fuentes. La investigación se centra por completo en el único aspecto de la representación de fuentes: legibilidad en tamaños pequeños. Crear un renderizador excelente para fuentes grandes es bastante simple, pero es increíblemente difícil escribir un sistema que mantenga la legibilidad en tamaños pequeños. El estudio del renderizado de fuentes tiene una larga historia, sorprendente en su profundidad. Lea, por ejemplo, sobre la tragedia de la trama . Es lógico que este fuera el principal problema para los especialistas en computación, porque en las primeras etapas de las computadoras, la resolución de la pantalla era bastante baja. Esta debe haber sido una de las primeras tareas que los desarrolladores de sistemas operativos tuvieron que hacer frente: ¿cómo hacer que el texto sea legible en dispositivos con baja resolución de pantalla? Para mi sorpresa, los sistemas de renderizado de fuentes de alta calidad están muy orientados a píxeles. Por ejemplo, un glifo se construye de tal manera que comienza en el borde del píxel, su ancho es un múltiplo del número de píxeles y el contenido se ajusta para adaptarse a los píxeles. Esta técnica se llama mallado. Estoy acostumbrado a trabajar con juegos de computadora y gráficos en 3D, donde el mundo está construido a partir de unidades y se proyecta en píxeles, así que me sorprendió un poco. Descubrí que en el campo del renderizado de fuentes es una elección muy importante.

Para mostrar la importancia de la malla, veamos un posible escenario para la rasterización de glifos. Imagine que un glifo se rasteriza en una cuadrícula de píxeles, pero la forma del glifo no coincide perfectamente con la estructura de la cuadrícula:


Antialiasing hará que los píxeles a la derecha e izquierda del glifo sean igualmente grises. Si el glifo se desplaza ligeramente para que coincida mejor con los bordes de los píxeles, solo se coloreará un píxel y se volverá completamente negro:


Ahora que el glifo coincide bien con los píxeles, los colores se han vuelto menos borrosos. La diferencia en nitidez es muy grande. Las fuentes occidentales tienen muchos glifos con líneas horizontales y verticales, y si no coinciden bien con la cuadrícula de píxeles, los tonos de gris hacen que la fuente sea borrosa. Incluso la mejor técnica anti-aliasing no puede hacer frente a este problema.

La sugerencia de fuentes fue propuesta como una solución. Los autores de fuentes deben agregar información a sus fuentes sobre cómo los glifos deben ajustarse a píxeles si no se ajustan perfectamente. El sistema de representación de fuentes distorsiona estas curvas para ajustarlas a la cuadrícula de píxeles. Esto aumenta enormemente la claridad de la fuente, pero tiene un precio:

  • Las fuentes se distorsionan ligeramente. Las fuentes no se ven exactamente como se esperaba.
  • Todos los glifos deben estar unidos a la cuadrícula de píxeles: el comienzo del glifo y el ancho del glifo. Por lo tanto, es imposible animarlos entre píxeles.

Curiosamente, al resolver este problema, Apple y Microsoft fueron de diferentes maneras. Microsoft se adhiere a la claridad absoluta, y Apple busca mostrar las fuentes con mayor precisión. En Internet puede encontrar personas quejándose de las fuentes borrosas en las máquinas Apple, pero a muchas personas les gusta lo que ven en Apple. Eso es en parte una cuestión de gustos. Aquí está la publicación de Joel sobre software, y aquí está la publicación de Peter Bilak sobre este tema, pero si busca en Internet, puede encontrar mucha más información.

Dado que la resolución DPI en pantallas modernas está aumentando rápidamente, surge la pregunta de si se necesitarán sugerencias de fuentes en el futuro, como lo es hoy. En mi estado actual, encuentro que la fuente insinúa una técnica muy valiosa para representar fuentes con claridad. Sin embargo, la técnica descrita en mi artículo puede convertirse en una alternativa interesante en el futuro, porque los glifos se pueden colocar libremente en el lienzo sin distorsión. Y dado que esta es esencialmente una técnica anti-aliasing, se puede usar para cualquier propósito, y no solo para renderizar fuentes.

Finalmente, hablaré brevemente sobre la representación de subpíxeles . En el pasado, la gente se dio cuenta de que puede triplicar la resolución horizontal de la pantalla utilizando los rayos individuales rojo, verde y azul de un monitor de computadora. Cada píxel se construye a partir de estos rayos, que están físicamente separados. Nuestro ojo mezcla sus valores, creando un solo color de píxel. Cuando el glifo cubre solo una parte del píxel, entonces solo se activa el haz que se superpone al glifo, lo que triplica la resolución horizontal. Si amplía la imagen de la pantalla utilizando una técnica como ClearType, puede ver los colores alrededor de los bordes del glifo:


Curiosamente, el enfoque que analizaré en el artículo se puede extender a la representación de subpíxeles. Ya he implementado su prototipo. Su único inconveniente es que, debido a la adición de filtros en técnicas como ClearType, necesitamos tomar más muestras de textura. Quizás lo consideraré en el futuro.

Representación de glifos utilizando una cuadrícula uniforme


Supongamos que muestreamos un glifo con una resolución 16 veces el objetivo y lo guardamos en una textura. Describiré cómo se hace esto en la tercera parte del artículo. Un patrón de muestreo es una cuadrícula uniforme, es decir, 16 puntos de muestreo están distribuidos uniformemente sobre el texel. Cada glifo se procesa con la misma resolución que la resolución de destino, almacenamos 16 bits por texel y cada bit corresponde a una muestra. Como veremos en el proceso de cálculo de la máscara de cobertura, el orden de almacenamiento de las muestras es importante. En general, los puntos de muestreo y sus posiciones para un texel se ven así:


Obteniendo texels


Cambiaremos la ventana de píxeles por los bits de cobertura almacenados en los texels. Necesitamos responder a la siguiente pregunta: ¿cuántas muestras entrarán en nuestra ventana de píxeles? Está ilustrado por la siguiente imagen:


Aquí vemos cuatro texels, en los que un glifo se superpone parcialmente. Un píxel (indicado en azul) cubre parte de los texels. Necesitamos determinar cuántas muestras cruza nuestra ventana de píxeles. Primero necesitamos lo siguiente:

  • Calcule la posición relativa de la ventana de píxeles en comparación con 4 texels.
  • Obtenga los texels con los que se cruza nuestra ventana de píxeles.

Nuestra implementación se basa en OpenGL, por lo que el origen del espacio de textura comienza en la parte inferior izquierda. Comencemos calculando la posición relativa de la ventana de píxeles. La coordenada UV pasada al sombreador de píxeles es la coordenada UV del centro del píxel. Suponiendo que los rayos UV están normalizados, primero podemos convertir los rayos UV en espacio de texel multiplicándolo por el tamaño de la textura. Restando 0.5 del centro del píxel, obtenemos la esquina inferior izquierda de la ventana de píxeles. Al redondear este valor hacia abajo, calculamos la posición inferior izquierda del texel inferior izquierdo. La imagen muestra un ejemplo de estos tres puntos en el espacio texel:


La diferencia entre la esquina inferior izquierda del píxel y la esquina inferior izquierda de la cuadrícula de texel es la posición relativa de la ventana de píxeles en coordenadas normalizadas. En esta imagen, la posición de la ventana de píxeles será [0.69, 0.37]. En código:

vec2 bottomLeftPixelPos = uv * size -0.5;
vec2 bottomLeftTexelPos = floor(bottomLeftPixelPos);
vec2 weigth = bottomLeftPixelPos - bottomLeftTexelPos;


Usando la instrucción textureGather, podemos obtener cuatro texels a la vez. Está disponible solo en OpenGL 4.0 y superior, por lo que puede ejecutar cuatro texelFetch en su lugar. Si solo pasamos las coordenadas UV de textureGather, entonces, con la combinación perfecta de la ventana de píxeles con el texel, surgirá un problema:


Aquí vemos tres texel horizontales con una ventana de píxeles (que se muestra en azul) que coincide exactamente con el texel central. El peso calculado es cercano a 1.0, pero textureGather eligió los texels centrales y derechos. La razón es que los cálculos realizados por textureGather pueden diferir ligeramente del cálculo del peso en coma flotante. La diferencia en el redondeo de los cálculos de GPU y los cálculos de peso de coma flotante produce fallas en los centros de los píxeles.

Para resolver este problema, debe asegurarse de que los cálculos de peso coincidan con el muestreo de TextureGather. Para hacer esto, nunca tomaremos muestras de centros de píxeles, y en su lugar, siempre tomaremos muestras en el centro de la cuadrícula de texel 2 × 2. Desde la posición inferior de texel calculada y ya redondeada hacia abajo, agregamos texel completo para llegar al centro de la cuadrícula de texel.


Esta imagen muestra que usando el centro de la cuadrícula de texel, los cuatro puntos de muestreo tomados por textureGather siempre estarán en el centro de los texel. En código:

vec2 centerTexelPos = (bottomLeftTexelPos + vec2(1.0, 1.0)) / size;
uvec4 result = textureGather(fontSampler, centerTexelPos, 0);


Máscara horizontal de ventana de píxeles


Tenemos cuatro texels y juntos forman una cuadrícula de 8 × 8 bits de cobertura. Para contar los bits en una ventana de píxeles, primero debemos restablecer los bits fuera de la ventana de píxeles. Para hacer esto, crearemos una máscara de ventana de píxeles y realizaremos Y bit a bit entre la máscara de píxeles y las máscaras de cobertura de texel. El enmascaramiento horizontal y vertical se realiza por separado.

La máscara de píxeles horizontal debe moverse junto con el peso horizontal, como se muestra en esta animación:


La imagen muestra una máscara de 8 bits con el valor 0x0F0 desplazándose hacia la derecha (los ceros se insertan a la izquierda). En la animación, una máscara se anima linealmente con peso, pero en realidad, un cambio de bit es una operación paso a paso. La máscara cambia de valor cuando la ventana de píxeles cruza el borde de la muestra. En la siguiente animación, esto se muestra en columnas rojas y verdes, animadas paso a paso. El valor cambia solo cuando los centros de las muestras se cruzan:


Para que la máscara se mueva solo en el centro de la celda, pero no en sus bordes, un simple redondeo es suficiente:

unsigned int pixelMask = 0x0F0 >> int(round(weight.x * 4.0));

Ahora tenemos una máscara de píxeles de una cadena completa de 8 bits que abarca dos texels. Si elegimos el tipo correcto de almacenamiento en nuestra máscara de cobertura de 16 bits, entonces hay formas de combinar el texel izquierdo y derecho y realizar el enmascaramiento horizontal de píxeles para una línea completa de 8 bits a la vez. Sin embargo, esto se vuelve problemático con el enmascaramiento vertical cuando pasamos a las cuadrículas rotadas. Por lo tanto, en cambio, combinamos entre sí dos texels izquierdos y por separado dos texels derechos para crear dos máscaras de cobertura de 32 bits. Enmascaramos los resultados izquierdo y derecho por separado.

Las máscaras para los texels izquierdos usan los 4 bits superiores de la máscara de píxeles, y las máscaras para los texels derechos usan los 4 bits inferiores. En una cuadrícula uniforme, cada fila tiene la misma máscara horizontal, por lo que podemos copiar la máscara para cada fila, después de lo cual la máscara horizontal estará lista:

unsigned int leftRowMask = pixelMask >> 4;
unsigned int rightRowMask = pixelMask & 0xF;
unsigned int leftMask = (leftRowMask << 12) | (leftRowMask << 8) | (leftRowMask << 4) | leftRowMask;
unsigned int rightMask = (rightRowMask << 12) | (rightRowMask << 8) | (rightRowMask << 4) | rightRowMask;


Para enmascarar, combinamos dos texels izquierdos y dos texels derechos, y luego enmascaramos las líneas horizontales:

unsigned int left = ((topLeft & leftMask) << 16) | (bottomLeft & leftMask);
unsigned int right = ((topRight & rightMask) << 16) | (bottomRight & rightMask);


Ahora el resultado puede verse así:


Ya podemos contar los bits de este resultado utilizando la instrucción bitCount. Deberíamos dividir no entre 16, sino entre 32, porque después del enmascaramiento vertical todavía podemos tener 32 bits potenciales, y no 16. Aquí está la representación completa del glifo en esta etapa:


Aquí vemos una letra S ampliada representada en base a los datos del vector original (contorno blanco) y la visualización de los puntos de muestreo. Si el punto es verde, entonces está dentro del glifo, si es rojo, entonces no. La escala de grises muestra los tonos calculados en esta etapa. En el proceso de renderización de fuentes, existen muchas posibilidades de errores, que van desde la rasterización, la forma en que los datos se almacenan en un atlas de texturas y el cálculo del tono final. Tales visualizaciones son increíblemente útiles para validar cálculos. Son especialmente importantes para depurar artefactos a nivel de subpíxel.

Enmascaramiento vertical


Ahora estamos listos para enmascarar los bits verticales. Para enmascarar verticalmente, utilizamos un método ligeramente diferente. Para lidiar con el desplazamiento vertical, es importante recordar cómo guardamos los bits: en orden de filas. La línea inferior son los cuatro bits menos significativos, y la línea superior son los cuatro bits más significativos. Simplemente podemos limpiar uno por uno, desplazándolos según la posición vertical de la ventana de píxeles.

Crearemos una única máscara que cubra la altura completa de dos texels. Como resultado, queremos guardar cuatro líneas completas de texels y enmascarar todo lo demás, es decir, la máscara será de 4 × 4 bits, que es igual a 0xFFFF. Según la posición de la ventana de píxeles, cambiamos las líneas inferiores y borramos las líneas superiores.

int shiftDown = int(round(weightY * 4.0)) * 4;
left = (left >> shiftDown) & 0xFFFF;
right = (right >> shiftDown) & 0xFFFF;


Como resultado, también enmascaramos los bits verticales fuera de la ventana de píxeles:


Ahora es suficiente para nosotros contar los bits que quedan en los texels, lo que se puede hacer con la operación bitCount, luego dividir el resultado entre 16 y obtener el tono deseado.

float shade = (bitCount(left) + bitCount(right)) / 16.0;

Ahora el renderizado completo de la carta se ve así:


Continuará ...


En la segunda parte, daremos el siguiente paso y veremos cómo puede aplicar esta técnica a las cuadrículas giradas. Calcularemos este esquema:


Y veremos que casi todo esto se puede reducir a varias tablas.

Gracias a Sebastian Aaltonen ( @SebAaltonen ) por su ayuda para resolver el problema de textureGather y, por supuesto, a Michael van der Leu ( @MvdleeuwGG ) por sus ideas y conversaciones interesantes por las noches.

Source: https://habr.com/ru/post/es433670/


All Articles