Renderizando fontes usando máscaras de cobertura, parte 1

imagem

Quando começamos a desenvolver nosso criador de perfil de desempenho , sabíamos que faríamos quase toda a renderização da interface do usuário por conta própria. Logo tivemos que decidir qual abordagem escolher para renderizar fontes. Tivemos os seguintes requisitos:

  1. Devemos ser capazes de renderizar qualquer fonte de qualquer tamanho em tempo real para nos adaptarmos às fontes do sistema e seus tamanhos escolhidos pelos usuários do Windows.
  2. A renderização da fonte deve ser muito rápida, sem freios quando a renderização de fontes é permitida.
  3. Nossa interface do usuário tem várias animações suaves, para que o texto possa se mover suavemente pela tela.
  4. Deve ser legível com tamanhos de fonte pequenos.

Não sendo um grande especialista na época, procurei informações na Internet e encontrei muitas técnicas usadas para renderizar fontes. Também conversei com o diretor técnico da Guerrilla Games, Michail van der Leu. Essa empresa experimentou várias maneiras de renderizar fontes, e seu mecanismo de renderização foi um dos melhores do mundo. Mihil descreveu brevemente sua idéia para uma nova técnica de renderização de fontes. Embora já tivéssemos tido o suficiente das técnicas já disponíveis, essa ideia me intrigou e eu comecei a implementá-la, sem prestar atenção ao maravilhoso mundo da renderização de fontes que me abria.

Nesta série de artigos, descreverei em detalhes a técnica que usamos, dividindo a descrição em três partes:

  • Na primeira parte, aprenderemos como renderizar glifos em tempo real usando 16xAA, amostrados de uma grade uniforme.
  • Na segunda parte, avançaremos para a grade rotacionada para executar lindamente antialiasing de arestas horizontais e verticais. Também veremos como o shader finalizado é quase completamente reduzido a uma textura e a uma tabela de pesquisa.
  • Na terceira parte, aprenderemos como rasterizar glifos em tempo real usando o Compute e a CPU.

Você também pode ver os resultados finalizados no criador de perfil, mas aqui está um exemplo de tela com a fonte da interface do usuário do Segoe renderizada usando nosso renderizador de fontes:


Aqui está um aumento na letra S, um tamanho rasterizado de apenas 6x9 texels. Os dados vetoriais originais são renderizados como um caminho e o padrão de amostra girado é renderizado a partir de retângulos verdes e vermelhos. Como é renderizado com uma resolução muito superior a 6 × 9, os tons de cinza não são representados na sombra final dos pixels, exibe a tonalidade do sub-pixel. Essa é uma visualização de depuração muito útil para garantir que todos os cálculos no nível do subpixel estejam funcionando corretamente.


Idéia: armazenar revestimento em vez de sombra


O principal problema com o qual os renderizadores de fontes precisam lidar é exibir dados de fontes vetoriais escaláveis ​​em uma grade de pixels fixa. O método de transição do espaço vetorial para pixels acabados em diferentes técnicas é muito diferente. Na maioria dessas técnicas, os dados da curva são rasterizados antes da renderização em um armazenamento temporário (por exemplo, uma textura) para obter um tamanho específico em pixels. O armazenamento temporário é usado como um cache de glifo: quando o mesmo glifo é renderizado várias vezes, os glifos são retirados do cache e reutilizados para evitar nova rasterização.

A diferença na técnica é claramente visível na forma como os dados são armazenados em um formato de dados intermediário. Por exemplo, o sistema de fontes do Windows rasteriza glifos para um tamanho específico em pixels. Os dados são armazenados como um matiz por pixel. A sombra descreve a melhor aproximação da cobertura pelo glifo deste pixel. Ao renderizar, os pixels são simplesmente copiados do cache de glifos para a grade de pixels de destino. Ao converter dados para um formato de pixel, eles não são redimensionados bem; portanto, ao diminuir o zoom, aparecem glifos difusos e, ao aumentar o zoom, aparecem glifos nos quais os blocos são claramente visíveis. Portanto, para cada tamanho final, os glifos são renderizados no cache de glifos.

Campos distanciados assinados usam uma abordagem diferente. Em vez de matiz para o pixel, a distância até a borda mais próxima do glifo é mantida. A vantagem desse método é que, para arestas curvas, os dados são dimensionados muito melhor do que tons. À medida que o glifo aumenta o zoom, as curvas permanecem suaves. A desvantagem dessa abordagem é que as arestas retas e afiadas são suavizadas. Muito melhor que o SDF é alcançado por soluções avançadas como o FreeType , que armazenam dados de cores.

Nos casos em que um matiz é retido para um pixel, você deve primeiro calcular sua cobertura. Por exemplo, stb_truetype tem bons exemplos de como você pode calcular a cobertura e o matiz. Outra maneira popular de aproximar a cobertura é amostrar o glifo com uma frequência mais alta que a resolução final. Isso conta o número de amostras que se encaixam no glifo na área de pixel de destino. O número de ocorrências dividido pelo número máximo de amostras possíveis determina o matiz. Como a cobertura já foi convertida em um matiz para uma resolução e alinhamento específicos da grade de pixels, é impossível colocar glifos entre os pixels de destino: o matiz não pode refletir corretamente a cobertura verdadeira com amostras da janela de pixels de destino. Por isso, além de outros motivos que consideraremos posteriormente, esses sistemas não suportam o movimento de subpixel.

Mas e se precisarmos mover livremente o glifo entre os pixels? Se a matiz for calculada com antecedência, não podemos descobrir qual deve ser a matiz ao mover-se entre pixels na área de pixels de destino. No entanto, podemos adiar a conversão da cobertura para o matiz no momento da renderização. Para fazer isso, não armazenaremos a sombra, mas o revestimento . Amostramos um glifo com uma frequência de 16 resoluções de destino e, para cada amostra, salvamos um único bit. Ao amostrar em uma grade 4 × 4, basta armazenar apenas 16 bits por pixel. Esta será a nossa máscara de cobertura . Durante a renderização, precisamos contar quantos bits entram na janela de pixels de destino, que tem a mesma resolução que o repositório texel, mas não está fisicamente anexado a ele. A animação abaixo mostra uma parte do glifo (azul) rasterizada em quatro texels. Cada texel é dividido em uma grade de células 4 × 4. Um retângulo cinza indica uma janela de pixel que se move dinamicamente pelo glifo. No tempo de execução, o número de amostras que caem na janela de pixels é contado para determinar o matiz.


Brevemente sobre as técnicas básicas de renderização de fontes


Antes de começar a discutir a implementação do nosso sistema de renderização de fontes, quero falar brevemente sobre as principais técnicas usadas neste processo: dicas de fontes e renderização de subpixel (essa técnica é chamada ClearType no Windows). Você pode pular esta seção se estiver interessado apenas nas técnicas de suavização de borda.

No processo de implementação do renderizador, aprendi cada vez mais sobre a longa história do desenvolvimento da renderização de fontes. A pesquisa se concentra inteiramente no único aspecto da renderização de fontes - legibilidade em tamanhos pequenos. Criar um excelente renderizador para fontes grandes é bastante simples, mas é incrivelmente difícil criar um sistema que mantenha a legibilidade em tamanhos pequenos. O estudo da renderização de fontes tem uma longa história, marcante em sua profundidade. Leia, por exemplo, sobre a tragédia raster . É lógico que este foi o principal problema para os especialistas em computadores, porque nos estágios iniciais dos computadores, a resolução da tela era bastante baixa. Essa deve ter sido uma das primeiras tarefas com as quais os desenvolvedores de sistemas operacionais tiveram que lidar: como tornar o texto legível em dispositivos com baixa resolução de tela? Para minha surpresa, os sistemas de renderização de fontes de alta qualidade são muito orientados a pixels. Por exemplo, um glifo é construído de tal maneira que começa na borda do pixel, sua largura é um múltiplo do número de pixels e o conteúdo é ajustado para caber nos pixels. Essa técnica é chamada de malha. Estou acostumado a trabalhar com jogos de computador e gráficos 3D, onde o mundo é construído a partir de unidades e projetado em pixels, então fiquei um pouco surpreso. Eu descobri que no campo da renderização de fontes, essa é uma escolha muito importante.

Para mostrar a importância da criação de malhas, vejamos um cenário possível para a rasterização de glifos. Imagine que um glifo é rasterizado em uma grade de pixels, mas a forma do glifo não corresponde perfeitamente à estrutura da grade:


O antialiasing tornará os pixels à direita e à esquerda do glifo igualmente cinzentos. Se o glifo for ligeiramente deslocado para corresponder melhor às bordas dos pixels, apenas um pixel será colorido e ficará completamente preto:


Agora que o glifo combina bem com os pixels, as cores ficaram menos borradas. A diferença de nitidez é muito grande. As fontes ocidentais têm muitos glifos com linhas horizontais e verticais e, se não corresponderem bem à grade de pixels, os tons de cinza tornarão a fonte embaçada. Mesmo a melhor técnica de anti-aliasing não é capaz de lidar com esse problema.

Sugestões de fontes foram propostas como uma solução. Os autores de fontes devem adicionar informações às fontes sobre como os glifos devem se ajustar aos pixels se não couberem perfeitamente. O sistema de renderização da fonte distorce essas curvas para ajustá-las à grade de pixels. Isso aumenta muito a clareza da fonte, mas tem um preço:

  • As fontes ficam ligeiramente distorcidas . As fontes não parecem exatamente como pretendidas.
  • Todos os glifos devem ser anexados à grade de pixels: o início do glifo e a largura do glifo. Portanto, é impossível animá-los entre pixels.

Curiosamente, para resolver esse problema, Apple e Microsoft foram de diferentes maneiras. A Microsoft adere à clareza absoluta e a Apple procura exibir fontes com mais precisão. Na Internet, você pode encontrar pessoas reclamando sobre fontes borradas nas máquinas da Apple, mas muitas pessoas gostam do que vêem na Apple. Isso é parcialmente uma questão de gosto. Aqui está o post de Joel on Software, e aqui está o post de Peter Bilak sobre esse tópico, mas se você pesquisar na Internet, poderá encontrar muito mais informações.

Como a resolução de DPI nas telas modernas está aumentando rapidamente, surge a questão de saber se serão necessárias dicas de fontes no futuro, como é hoje. No meu estado atual, acho a fonte sugerindo uma técnica muito valiosa para renderizar fontes claramente. No entanto, a técnica descrita em meu artigo pode se tornar uma alternativa interessante no futuro, porque os glifos podem ser livremente colocados na tela sem distorção. E como essa é essencialmente uma técnica de suavização de serrilhado, pode ser usada para qualquer finalidade, e não apenas para renderizar fontes.

Por fim, falarei brevemente sobre a renderização de subpixel . No passado, as pessoas percebiam que era possível triplicar a resolução horizontal da tela usando os raios vermelho, verde e azul individuais de um monitor de computador. Cada pixel é construído a partir desses raios, que são fisicamente separados. Nossos olhos misturam seus valores, criando uma única cor de pixel. Quando o glifo cobre apenas parte do pixel, somente o feixe sobreposto ao glifo é ativado, o que triplica a resolução horizontal. Se você ampliar a imagem da tela usando uma técnica como ClearType, poderá ver as cores nas bordas do glifo:


Curiosamente, a abordagem que discutirei no artigo pode ser estendida à renderização sub-pixel. Eu já implementei seu protótipo. Sua única desvantagem é que, devido à adição de filtragem em técnicas como o ClearType, precisamos coletar mais amostras de textura. Talvez eu considere isso no futuro.

Renderização de glifo usando uma grade uniforme


Suponha que tenhamos amostrado um glifo com uma resolução 16 vezes maior do que o alvo e o salvado em uma textura. Vou descrever como isso é feito na terceira parte do artigo. Um padrão de amostragem é uma grade uniforme, ou seja, 16 pontos de amostragem são distribuídos igualmente sobre o texel. Cada glifo é renderizado com a mesma resolução que a resolução de destino, armazenamos 16 bits por texel e cada bit corresponde a uma amostra. Como veremos no processo de cálculo da máscara de cobertura, a ordem de armazenamento das amostras é importante. Em geral, os pontos de amostragem e suas posições para um texel são assim:


Obtendo texels


Mudaremos a janela de pixels pelos bits de cobertura armazenados nos texels. Precisamos responder à seguinte pergunta: quantas amostras entrarão na nossa janela de pixels? É ilustrado pela seguinte imagem:


Aqui vemos quatro texels, nos quais um glifo é parcialmente sobreposto. Um pixel (indicado em azul) cobre parte dos texels. Precisamos determinar quantas amostras nossa janela de pixels cruza. Primeiro, precisamos do seguinte:

  • Calcule a posição relativa da janela de pixels em comparação com 4 texels.
  • Obtenha os texels com os quais nossa janela de pixels intercepta.

Nossa implementação é baseada no OpenGL, portanto a origem do espaço da textura começa na parte inferior esquerda. Vamos começar calculando a posição relativa da janela de pixels. A coordenada UV passada para o pixel shader é a coordenada UV do centro do pixel. Supondo que os UVs sejam normalizados, podemos primeiro converter UVs em espaço texel multiplicando-o pelo tamanho da textura. Subtraindo 0,5 do centro do pixel, obtemos o canto inferior esquerdo da janela de pixel. Arredondando esse valor, calculamos a posição inferior esquerda do texel inferior esquerdo. A imagem mostra um exemplo desses três pontos no espaço texel:


A diferença entre o canto inferior esquerdo do pixel e o canto inferior esquerdo da grade texel é a posição relativa da janela de pixel nas coordenadas normalizadas. Nesta imagem, a posição da janela de pixels será [0,69, 0,37]. No código:

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


Usando a instrução textureGather, podemos obter quatro texels por vez. Está disponível apenas no OpenGL 4.0 e superior, para que você possa executar quatro texelFetch. Se passarmos as coordenadas UV de textureGather, em seguida, com uma combinação perfeita da janela de pixels com o texel, um problema surgirá:


Aqui vemos três texels horizontais com uma janela de pixel (mostrada em azul) que corresponde exatamente ao texel central. O peso calculado é próximo a 1,0, mas o textureGather escolheu o centro e os texels certos. O motivo é que os cálculos realizados pelo textureGather podem diferir um pouco do cálculo do peso do ponto flutuante. A diferença no arredondamento dos cálculos da GPU e do peso do ponto flutuante resulta em falhas nos centros de pixels.

Para resolver esse problema, é necessário garantir que os cálculos de peso sejam compatíveis com a amostragem textureGather. Para fazer isso, nunca iremos amostrar centros de pixels e, em vez disso, sempre amostraremos no centro da grade texel 2 × 2. A partir da posição inferior texel calculada e já arredondada para baixo, adicionamos texel completo para chegar ao centro da grade texel.


Esta imagem mostra que, usando o centro da grade texel, os quatro pontos de amostragem obtidos pelo textureGather sempre estarão no centro dos texels. No código:

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


Máscara horizontal de janela de pixel


Temos quatro texels e juntos eles formam uma grade de 8 × 8 bits de cobertura. Para contar os bits em uma janela de pixels, primeiro precisamos redefinir os bits fora da janela de pixels. Para fazer isso, criaremos uma máscara de janela de pixel e executaremos AND bit a bit entre a máscara de pixel e as máscaras de cobertura texel. As máscaras horizontal e vertical são realizadas separadamente.

A máscara de pixel horizontal deve se mover junto com o peso horizontal, conforme mostrado nesta animação:


A imagem mostra uma máscara de 8 bits com o valor 0x0F0 deslocando-se para a direita (zeros são inseridos à esquerda). Na animação, uma máscara é linearmente animada com o peso, mas, na realidade, uma mudança de bit é uma operação passo a passo. A máscara altera o valor quando a janela de pixels cruza a borda da amostra. Na próxima animação, isso é mostrado em colunas vermelhas e verdes, animadas passo a passo. O valor muda apenas quando os centros das amostras se cruzam:


Para que a máscara se mova apenas no centro da célula, mas não nas bordas, basta um arredondamento simples:

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

Agora, temos uma máscara de pixel de uma string completa de 8 bits, abrangendo dois texels. Se escolhermos o tipo certo de armazenamento em nossa máscara de cobertura de 16 bits, há maneiras de combinar o texel esquerdo e direito e executar o mascaramento de pixel horizontal para uma linha completa de 8 bits por vez. No entanto, isso se torna problemático com o mascaramento vertical quando passamos para grades rotacionadas. Portanto, em vez disso, combinamos dois texels esquerdos e separadamente dois texels direitos para criar duas máscaras de cobertura de 32 bits. Nós mascaramos os resultados esquerdo e direito separadamente.

Máscaras para texels esquerdo usam os 4 bits superiores da máscara de pixel e máscaras para texels direitos usam os 4 bits inferiores. Em uma grade uniforme, cada linha tem a mesma máscara horizontal, para que possamos copiar a máscara para cada linha, após o que a máscara horizontal estará pronta:

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 mascarar, combinamos dois texels da esquerda e dois da direita e depois mascaramos as linhas horizontais:

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


Agora o resultado pode ficar assim:


Já podemos contar os bits desse resultado usando a instrução bitCount. Devemos dividir não por 16, mas por 32, porque após o mascaramento vertical ainda podemos ter 32 bits em potencial, e não 16. Aqui está a renderização completa do glifo nesta fase:


Aqui vemos uma letra S ampliada renderizada com base nos dados vetoriais originais (contorno branco) e na visualização dos pontos de amostragem. Se o ponto estiver verde, ele estará dentro do glifo, se vermelho, e não. A escala de cinza exibe os matizes calculados nesta fase. No processo de renderização de fontes, existem muitas possibilidades de erros, desde a rasterização, a maneira como os dados são armazenados em um atlas de textura e o cálculo do matiz final. Essas visualizações são incrivelmente úteis para validar cálculos. Eles são especialmente importantes para depurar artefatos no nível de sub-pixel.

Mascaramento vertical


Agora estamos prontos para mascarar os bits verticais. Para mascarar verticalmente, usamos um método ligeiramente diferente. Para lidar com o deslocamento vertical, é importante lembrar como salvamos os bits: na ordem das linhas. A linha inferior são os quatro bits menos significativos e a linha superior são os quatro bits mais significativos. Podemos simplesmente limpar um por um, alterando-os com base na posição vertical da janela de pixels.

Criaremos uma única máscara cobrindo toda a altura de dois texels. Como resultado, queremos salvar quatro linhas completas de texels e mascarar todo o resto, ou seja, a máscara terá 4 × 4 bits, o que é igual a 0xFFFF. Com base na posição da janela de pixels, alteramos as linhas inferiores e limpamos as linhas superiores.

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


Como resultado, também ocultamos os bits verticais fora da janela de pixels:


Agora basta contar os bits restantes nos texels, o que pode ser feito com a operação bitCount, depois dividir o resultado por 16 e obter a tonalidade desejada!

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

Agora, a renderização completa da carta fica assim:


Para continuar ...


Na segunda parte, daremos o próximo passo e veremos como você pode aplicar essa técnica às grades rotacionadas. Vamos calcular este esquema:


E veremos que quase tudo isso pode ser reduzido a várias tabelas.

Agradecemos a Sebastian Aaltonen ( @SebAaltonen ) por sua ajuda na solução do problema do TextureGather e, é claro, a Michael van der Leu ( @MvdleeuwGG ) por suas idéias e conversas interessantes à noite.

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


All Articles