Renderizando texto sem textura
(poniesandlight.co.uk)- Em situações em que é preciso exibir rapidamente mensagens de depuração dentro de um render pass, preparar um atlas de fontes é um peso desnecessário, então é útil uma abordagem que desenha texto usando apenas constantes do fragment shader
- Os glifos são representados como bitmaps de 8x16 pixels, e 96 caracteres ASCII imprimíveis são armazenados em um array
uvec4de 1536 bytes para consulta direta no shader - Os dados da fonte podem ser gerados a partir de uma fonte de terminal PSF1, pulando o cabeçalho de 4 bytes e a seção de 512 bytes de glifos não imprimíveis, e então extraindo os 96 glifos seguintes
- A renderização usa uma estrutura per-instance com
uint32_tem unidades de 4 caracteres e posição/escala, desenhando todo o texto com uma única instanced draw call - Como a técnica é voltada para saída simples de depuração, limitações como padding de 4 caracteres, caractere de preenchimento
\0comdiscarde correção de endianness precisam ser tratadas em conjunto pela aplicação e pelo shader
Usando constantes no shader em vez de atlas de fontes
- A renderização de texto tradicional renderiza os glifos possíveis em um atlas de fontes, faz o bind disso como textura e então desenha triângulos correspondentes a cada glifo
imguiestb_truetypetambém usam essa abordagem, mas para exibir rapidamente mensagens de depuração o processo de preparação pode ser inconveniente- Uma alternativa é armazenar os dados equivalentes ao atlas de fontes como constantes inteiras dentro do fragment shader
- Inteiros podem ser usados como bitmap, então a posição
xydo fragmento é mapeada para uma posição específica de bit, e se esse bit estiver ligado, sai a cor de primeiro plano; se estiver desligado, sai a cor de fundo
Glifos bitmap 8x16 e tabela ASCII
- Como um byte representa apenas uma linha de pixels, são usados 16 bytes por glifo para produzir glifos mais legíveis
- Cada glifo se torna uma área de 8x16 pixels, e um
uvec4do GLSL comporta exatamente os 16 bytes necessários - Armazenando 96 glifos ASCII imprimíveis, o total de dados fica em 1536 bytes
- O array
font_data[96]usa como índice o valor ASCII menos0x20- O alvo são os glifos ASCII imprimíveis começando em
0x20SPACE - No código de exemplo, apenas algumas entradas são mostradas para economizar espaço
- O alvo são os glifos ASCII imprimíveis começando em
- A tabela completa de bitmaps está incluída no código-fonte do Island
Extraindo bitmaps de uma fonte PSF1
- A codificação de bitmap necessária quase coincide com a de fontes de terminal no formato PSF1
- O procedimento para extrair os dados de uma fonte de terminal PSF1 é simples
- Abrir o arquivo da fonte em um editor hexadecimal como o ImHex
- Pular o cabeçalho de 4 bytes
- Pular a seção de 512 bytes de glifos não imprimíveis
- Exportar os 96 glifos seguintes, ou seja, 1536 bytes, com “Copy as → C Array”
- O array extraído de
charpode então ser ajustado para um array deuinte agrupado em unidades deuvec4 - Se os
charbrutos forem simplesmente concatenados emuint, a endianness fica invertida, então isso é corrigido novamente na hora da amostragem - Os dados originais da fonte pixel usada foram obtidos da fonte pixel gratuita Tamsyn de Scott Fial
Estrutura de uma única instanced draw call
- A renderização do texto é feita com uma única instanced draw call
- A draw call usa dois fluxos de atributos
- O fluxo per-draw contém apenas as informações necessárias para desenhar um quad comum
- O fluxo per-instance contém o deslocamento de posição na tela e o texto a ser exibido
- No deslocamento de posição são usados floats
xey, e o espaço restante de float pode armazenar o valor de escala da fonte - No Vulkan, todos os componentes de um vertex output binding precisam ter a mesma característica de interpolação, então não é fácil misturar de forma limpa
vec3euintno mesmo binding - O texto é empacotado em
uint32_tem unidades de 4 caracteres- Como o menor tipo básico de dados de atributo de vértice normalmente é de 32 bits, quatro caracteres são armazenados de uma vez
- O comprimento da mensagem precisa ser divisível por 4
- As partes faltantes são preenchidas com o caractere
\0
- Os dados per-instance são representados pela estrutura
word_datapos_and_scale[3]: posiçãoxye escalaword: os quatro caracteres a imprimir
- A aplicação divide a mensagem em chunks de 4 caracteres, converte cada chunk em
uint32_te o acumula em um array deword_datajunto com o deslocamento de posição - Na hora da renderização, esse array é associado ao binding per-instance do pipeline de desenho de texto de depuração, e são desenhadas instâncias na quantidade de quads
Enviando posição e caracteres no Vertex Shader
- O vertex shader produz
gl_Position, aworda ser renderizada e valores equivalentes a coordenadas de textura gl_Positionusa os dados per-instancepos_and_scalepara posicionar os vértices do triângulo na tela no sistema de coordenadas NDC- A word a ser renderizada é passada do atributo de entrada
uintdiretamente para o fragment shader- O qualifier
flaté usado para evitar interpolação
- O qualifier
- As coordenadas de textura são sintetizadas com
gl_VertexIndex12 >> gl_VertexIndex & 1gera a sequência0, 0, 1, 19 >> gl_VertexIndex & 1gera a sequência1, 0, 0, 1- Essa combinação gera as coordenadas uv
(0,1), (0,0), (1,0), (1,1)sem ramificações
- O vertex shader também recebe a cor de primeiro plano e a cor de fundo como dados per-instance e as repassa ao fragment shader
Amostragem de glifos no Fragment Shader
- Para renderizar o texto, o fragment shader precisa de três informações
- As coordenadas uv interpoladas do fragmento
- Os dados do caractere a ser exibido em
in_word - O array de bitmaps de glifos
font_data
- As coordenadas uv ficam no intervalo float normalizado de
vec2(0.f,0.f)atévec2(1.f,1.f), enquanto as coordenadas de pixel do glifo vão deuvec2(0,0)atéuvec2(7,15) - A word inteira de 4 caracteres é tratada como uma área de 32 pixels de largura por 16 de altura
uv.xy * vec2(8 * WORD_LEN, 16)é quantizado comfloorpara coordenadas de pixel da word- O intervalo de coordenadas é limitado a
uvec2(0..31, 0..15) word_pixel_coord.x / 8identifica em qual das quatro áreas de caractere está o pixelword_pixel_coord.x % 8obtém a coordenadaxdentro do glifo
- O código do caractere é convertido em índice de
font_data- Como o primeiro glifo é
0x20SPACE, usa-seprintable_character - 0x20como offset - O bitmap do glifo correspondente é obtido como
uvec4nesse offset
- Como o primeiro glifo é
- A coordenada
yescolhe umuintespecífico dentro douvec4por meio deglyph_pixel_coord.y / 4- Esse
uintcontém os dados de pixel de 4 linhas - Como os
charextraídos do ImHex foram concatenados diretamente para formar umuint, a ordem das linhas fica invertida - Isso é corrigido indexando de trás para frente com
(8*(3-(glyph_pixel_coord.y)%4))
- Esse
- A coordenada
xescolhe o bit com7-glyph_pixel_coord.x- Como o bit mais significativo do byte é armazenado no índice mais alto, é preciso indexar ao contrário para corresponder da esquerda para a direita
- O valor final
current_pixelé usado emmix(background_colour, foreground_colour, current_pixel)para definir a cor
Tratamento de strings curtas e caractere de preenchimento
- Se o comprimento da string não for divisível por 4, a aplicação preenche a parte faltante com o caractere
\0 - O fragment shader verifica se o caractere a ser exibido é
\0 - Ao encontrar um caractere de preenchimento
\0, ele não desenha nem mesmo o fundo e executadiscard - Isso permite manter a restrição de empacotamento em 4 caracteres e ainda assim exibir strings curtas
Forma de uso e localização do código
- No projeto Island, é possível exibir texto de depuração na tela chamando
le::DebugPrint - O código completo do fragment shader pode ser visto no github
- O exemplo de código mostra o envio de dados de string para exibir na tela uma mensagem no formato
"That's all, %s"
1 comentários
Opiniões do Hacker News
Se quiser experimentar por conta própria, basta acompanhar a aritmética. É muito fácil implementar do zero no ShaderToy, e, se você gosta desse tipo de coisa, também é uma brincadeira divertida para uma manhã de sábado
Fazer do zero é divertido, mas, se precisar de uma dica para começar, há um exemplo que acabei de criar: https://www.shadertoy.com/view/Mc3cW2
Também há muitos hacks de texto inteligentes feitos por outras pessoas, incluindo um exemplo de Matrix com menos de 300 caracteres https://www.shadertoy.com/view/llXSzj e um efeito de display CRT verde https://www.shadertoy.com/view/XtfSD8
vec2(30, -30)por 300, aparecem artefatosFico curioso se existe algum truque para lidar com isso direito. No meu caso, fazer multisampling da textura dentro do fragment shader foi o que funcionou melhor, mas ainda assim não ficou tão bom quanto o estado da arte
Um tempo atrás, tentei criar um app que parecesse uma fonte nativa de console, e precisei ficar mais de 2 horas ajustando só para chegar a uns 90% do resultado
Criativo, cheio de hacks e divertido. Na verdade, quase todas as técnicas de renderização 3D são assim, mas o resultado não é exatamente bonito, a menos que a intenção seja recriar um antigo BBS
Dá para melhorar adicionando mais bits, mas muito antes de ficar bonito você vai acabar encontrando uma forma mais fácil de definir todos esses bits. No fim, quase não haverá solução mais eficiente do que fazer em pixels preto e branco num programa de desenho e salvar como textura, então você volta ao ponto de partida
Se quiser saber o modo mais comum como motores modernos de renderização 3D desenham texto, procure por texto SDF e técnicas relacionadas, como MSDF. Em uma etapa de pré-processamento, usa-se um atlas de texturas tradicional para criar um atlas de campo de distância assinado
O artigo fala de hardware, mas também há reencarnações em software
Eu já fiz uma versão bem básica desse conceito: https://www.shadertoy.com/view/sdXBDs
Não estou dizendo que não seja um truque legal; de fato, é um truque legal
Também existe a opção de renderizar texto como mesh. O TextMeshPro vai um passo além e usa campos de distância assinados para lidar com escala arbitrária
https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...
Exemplo: https://sluglibrary.com
Mesh e SDF são muito mais simples do lado da GPU, mas podem perder precisão se ampliados demais, e, se você reduzir demais um mesh, pode surgir aliasing
Muito legal. Seria interessante ter uma comparação de desempenho com a abordagem “tradicional” por texturas
Em GPUs atuais, quando se trata de uma tarefa simples assim, a resposta para “e o desempenho?” geralmente parece ser “funciona”
Sebastian Lague fez um ótimo vídeo cobrindo várias técnicas de renderização de fontes
https://youtu.be/SO83KQuuZvg
Já experimentei uma técnica parecida, colocando todos os dados da fonte dentro do código-fonte do fragment shader. Assim, dá para imprimir diretamente com
snprintfem um buffer da GPU mapeado na CPU. Sei que é uma abordagem perigosaEm vez de desenhar caracteres individuais com um vertex shader, eu desenhava apenas um triângulo de tela inteira e usava
gl_FragCoordem vez de coordenadas UV. Não é a forma mais eficiente, mas era um recurso de debug e, na prática, era rápido o suficienteApesar do nome do arquivo, usei a fonte da ROM do IBM PC, não do NES. A “NES font” e outras fontes 8x8 pixels podem ser encontradas na web
https://github.com/rikusalminen/triangles/blob/nesfont/shade...
Curiosamente, esse jogo armazenava os dados de perguntas e respostas em fitas de 8 trilhas
Legal. Não é sempre que vejo um algoritmo de renderização de texto que eu ainda não tenha tentado pessoalmente. Na minha startup implementamos vários, mas eu precisava de independência de resolução e antialiasing, então essa abordagem não teria me ajudado
Talvez ela não generalize para todos os arquivos de fonte com curvas de Bézier. Converter curvas em pixels pode ser difícil, especialmente quando um glifo cruza consigo mesmo. No geral, a renderização de texto padrão parece um problema resolvido, e os casos de uso fora do padrão são muito cruéis de tentar
Conceitualmente, essa abordagem parece parecida com o método do Will Dobbie, de que gosto. Só que é muito mais simples. Ambas recebem dados brutos da fonte e os usam diretamente no shader. A diferença é que esta armazena os dados de pixels em um array, enquanto Will armazena os dados de caminhos SVG como uma “textura vetorial”
Se tiver curiosidade, há uma demo ótima do Will: https://wdobbie.com/warandpeace/
Antigamente pensei em tentar algo assim, mas eu entendia que a GPU era especialmente eficiente para renderizar texturas e relativamente lenta em manipulação de bits. Mesmo economizando um pouco de memória aqui, fico me perguntando se isso é realmente mais rápido do que usar um atlas
Talvez dê para obter o melhor dos dois mundos fazendo bit packing em uma textura comum e deixando o fragment shader decodificar
Pergunto por pura curiosidade, porque sei muito pouco sobre computação gráfica moderna: o custo de fazer upload de uma textura pequena para a GPU é tão alto assim? Fico pensando se não daria para renderizar a string inteira em uma textura 2D e depois exibir essa textura em dois triângulos
Em contraste, seria preciso escrever código para gerar um atlas de fontes, ou encontrar e carregar um atlas existente, o que também exigiria código de carregamento. Ou então desenhar a mensagem inteira em uma textura e armazenar esse resultado em cache até a mensagem mudar
Além disso, ainda há gerenciamento de recursos e binding, enquanto esta abordagem não precisa de recursos. Em outras palavras, não é uma solução geral para texto, mas uma técnica para exibir texto de debug na tela
Como referência, a maioria dos navegadores e sistemas operacionais funciona desenhando texto em texturas. Eles desenham fontes dinamicamente em um atlas de texturas e usam os glifos desse atlas para criar mais texturas de partes da janela do app. Se você ativar a exibição dos limites de texturas no navegador, consegue ver todas as texturas; Rendering->Layer borders contorna cada textura em ciano
Se a CPU, relativamente lenta, precisar renderizar muitas caixas de texto em texturas independentes, isso se acumula rapidamente e consome o orçamento
Desenhar usando um atlas de glifos ainda é muito melhor em termos de uso de recursos. Pipelines modernos de renderização de texto costumam usar SDFs ou curvas de Bézier codificadas para melhorar a legibilidade dos glifos ao ampliar/reduzir, e isso também é uma boa forma de economizar memória
Do ponto de vista de upload, no fim existem X bytes de glifos e eles precisam entrar de alguma forma na memória da GPU. Seja como textura, dados uniformes ou constantes de shader, não há grande diferença de desempenho. Na verdade, se forem colocados como constantes de shader, como no texto original, o custo do lado da CPU pode ser maior, porque o compilador de shader precisa processar todas as declarações de constantes
Do lado da GPU, o que importa é qual nível da hierarquia de memória é acessado ao ler os dados dos glifos. Fetches de textura usam um cache L1 dedicado na maioria das GPUs, e provavelmente maior que o cache L1 comum. A ordem dos dados também importa. Texturas costumam ser armazenadas em uma variação da ordem de Morton para evitar cache misses ao sombrear blocos de pixels. Para um renderizador de texto baseado em atlas em uso real, provavelmente é melhor usar textura
Edit: li a pergunta errado. Se a comparação era entre desenhar glifos individuais na GPU e desenhar o bloco de texto inteiro na CPU, então é uma troca entre velocidade e espaço. A resposta depende de quanta memória você quer usar para texto, se o texto muda, se precisa de efeitos por caractere etc.
Além disso, embora chamem de “sem textura”, esta abordagem também é uma textura. A textura só está armazenada em outro formato e em outro lugar. Renderização de fonte realmente sem textura avaliaria curvas vetoriais na hora
É bem confuso dizer que não vai armazenar um bitmap no shader e, ao mesmo tempo, explicar exatamente como armazenar um bitmap no shader
Em resumo, é uma fonte bitmap embutida no shader
Dá para comparar com a diferença entre armazenar dados em um arquivo separado que precisa ser lido em runtime e incluir os dados diretamente no código-fonte