Rendu des polices à l'aide de masques de couverture, partie 1

image

Lorsque nous avons commencé à développer notre profileur de performances , nous savions que nous ferions presque tout le rendu de l'interface utilisateur par nous-mêmes. Bientôt, nous avons dû décider quelle approche choisir pour le rendu des polices. Nous avions les exigences suivantes:

  1. Nous devons être capables de rendre n'importe quelle police de n'importe quelle taille en temps réel afin de nous adapter aux polices système et à leurs tailles choisies par les utilisateurs de Windows.
  2. Le rendu des polices doit être très rapide, aucun freinage lorsque le rendu des polices est autorisé.
  3. Notre interface utilisateur a un tas d'animations fluides, donc le texte devrait pouvoir se déplacer en douceur sur l'écran.
  4. Il doit être lisible avec de petites tailles de police.

N'étant pas un grand spécialiste à cette époque, j'ai cherché des informations sur Internet et trouvé de nombreuses techniques utilisées pour rendre les polices. J'ai également parlé au directeur technique des Jeux de Guerrilla, Michail van der Leu. Cette société a expérimenté de nombreuses façons de rendre les polices, et leur moteur de rendu était l'un des meilleurs au monde. Mihil a brièvement expliqué son idée d'une nouvelle technique de rendu des polices. Bien que nous en ayons eu assez des techniques déjà disponibles, cette idée m'a intrigué et j'ai commencé à l'implémenter, sans prêter attention au monde merveilleux du rendu des polices qui s'ouvrait à moi.

Dans cette série d'articles, je décrirai en détail la technique que nous utilisons, divisant la description en trois parties:

  • Dans la première partie, nous apprendrons à rendre des glyphes en temps réel à l'aide de 16xAA, échantillonnés à partir d'une grille uniforme.
  • Dans la deuxième partie, nous allons passer à la grille pivotée pour effectuer magnifiquement l'anticrénelage des bords horizontaux et verticaux. Nous verrons également comment le shader fini est presque complètement réduit à une texture et à une table de recherche.
  • Dans la troisième partie, nous apprendrons à pixelliser les glyphes en temps réel à l'aide de Calcul et CPU.

Vous pouvez également voir les résultats finaux dans le profileur, mais voici un exemple d'écran avec la police Segoe UI rendue à l'aide de notre rendu de police:


Voici une augmentation de la lettre S, une taille tramée de seulement 6x9 texels. Les données vectorielles d'origine sont rendues sous la forme d'un chemin et le motif d'échantillonnage pivoté est rendu à partir de rectangles verts et rouges. Puisqu'il est rendu avec une résolution bien supérieure à 6 × 9, les nuances de gris ne sont pas représentées dans la teinte finale des pixels, il affiche la teinte du sous-pixel. Il s'agit d'une visualisation de débogage très utile pour vous assurer que tous les calculs au niveau des sous-pixels fonctionnent correctement.


Idée: stocker le revêtement au lieu de l'ombre


Le principal problème auquel les rendus de polices doivent faire face est l'affichage des données de polices vectorielles évolutives dans une grille de pixels fixe. La méthode de transition de l'espace vectoriel aux pixels finis dans différentes techniques est très différente. Dans la plupart de ces techniques, les données de courbe sont tramées avant d'être rendues dans un stockage temporaire (par exemple, une texture) pour obtenir une taille spécifique en pixels. Le stockage temporaire est utilisé comme cache de glyphes: lorsque le même glyphe est rendu plusieurs fois, les glyphes sont extraits du cache et réutilisés pour éviter une nouvelle pixellisation.

La différence de technique est clairement visible dans la façon dont les données sont stockées dans un format de données intermédiaire. Par exemple, le système de polices Windows pixellise les glyphes à une taille spécifique en pixels. Les données sont stockées sous la forme d'une teinte par pixel. L'ombre décrit la meilleure approximation de la couverture par le glyphe de ce pixel. Lors du rendu, les pixels sont simplement copiés du cache de glyphes vers la grille de pixels cible. Lors de la conversion de données au format pixel, elles ne sont pas bien mises à l'échelle.Par conséquent, lors d'un zoom arrière, des glyphes flous apparaissent et lors d'un zoom avant, des glyphes apparaissent dans lesquels les blocs sont clairement visibles. Par conséquent, pour chaque taille finale, les glyphes sont rendus dans le cache de glyphes.

Les champs distants signés utilisent une approche différente. Au lieu de la teinte du pixel, la distance jusqu'au bord le plus proche du glyphe est conservée. L'avantage de cette méthode est que pour les bords incurvés, les données évoluent bien mieux que les nuances. Lorsque le glyphe se rapproche, les courbes restent lisses. L'inconvénient de cette approche est que les bords droits et tranchants sont lissés. Beaucoup mieux que SDF est atteint par des solutions avancées comme FreeType , qui stockent les données de couleur.

Dans les cas où une teinte est conservée pour un pixel, vous devez d'abord calculer sa couverture. Par exemple, stb_truetype a de bons exemples de la façon dont vous pouvez calculer la couverture et la teinte. Une autre façon populaire d'estimer la couverture consiste à échantillonner le glyphe à une fréquence plus élevée que la résolution finale. Cela compte le nombre d'échantillons qui tiennent dans le glyphe dans la zone de pixel cible. Le nombre de hits divisé par le nombre maximum d'échantillons possibles détermine la teinte. Étant donné que la couverture a déjà été convertie en une teinte pour une résolution et un alignement de grille de pixels spécifiques, il est impossible de placer des glyphes entre les pixels cibles: la teinte ne peut pas refléter correctement la vraie couverture avec des échantillons de la fenêtre de pixel cible. Pour cela, ainsi que pour d'autres raisons que nous examinerons plus loin, de tels systèmes ne prennent pas en charge le mouvement sous-pixel.

Mais que se passe-t-il si nous devons déplacer librement le glyphe entre les pixels? Si la teinte est calculée à l'avance, nous ne pouvons pas déterminer quelle devrait être la teinte lors du déplacement entre les pixels dans la zone de pixels cible. Cependant, nous pouvons retarder la conversion de la couverture en teinte au moment du rendu. Pour ce faire, nous ne conserverons pas l'ombre, mais le revêtement . Nous échantillonnons un glyphe avec une fréquence de 16 résolution cible, et pour chaque échantillon, nous enregistrons un seul bit. Lors d'un échantillonnage sur une grille 4 × 4, il suffit de stocker seulement 16 bits par pixel. Ce sera notre masque de couverture . Pendant le rendu, nous devons compter le nombre de bits entrant dans la fenêtre de pixel cible, qui a la même résolution que le référentiel de texels, mais qui n'y est pas physiquement attachée. L'animation ci-dessous montre une partie du glyphe (bleu) tramé en quatre texels. Chaque texel est divisé en une grille de 4 × 4 cellules. Un rectangle gris indique une fenêtre de pixels qui se déplace dynamiquement à travers le glyphe. Au moment de l'exécution, le nombre d'échantillons qui tombent dans la fenêtre de pixels est compté pour déterminer la teinte.


En bref sur les techniques de base de rendu des polices


Avant de passer à la mise en œuvre de notre système de rendu des polices, je voudrais parler brièvement des principales techniques utilisées dans ce processus: l'indication des polices et le rendu des sous-pixels (cette technique est appelée ClearType sous Windows). Vous pouvez ignorer cette section si vous êtes uniquement intéressé par les techniques d'anticrénelage.

Au cours de la mise en œuvre du moteur de rendu, j'ai appris de plus en plus sur la longue histoire du développement du rendu des polices. La recherche se concentre entièrement sur le seul aspect du rendu des polices - la lisibilité à de petites tailles. La création d'un excellent rendu pour les grandes polices est assez simple, mais il est incroyablement difficile d'écrire un système qui maintient la lisibilité à de petites tailles. L'étude du rendu des polices a une longue histoire, frappante dans sa profondeur. Lisez, par exemple, la tragédie raster . Il est logique que ce soit le principal problème pour les informaticiens, car au début des ordinateurs, la résolution de l'écran était assez faible. Cela a dû être l'une des premières tâches auxquelles les développeurs de systèmes d'exploitation ont dû faire face: comment rendre le texte lisible sur des appareils à faible résolution d'écran? À ma grande surprise, les systèmes de rendu de polices de haute qualité sont très orientés pixels. Par exemple, un glyphe est construit de telle manière qu'il commence au bord du pixel, sa largeur est un multiple du nombre de pixels et le contenu est ajusté pour s'adapter aux pixels. Cette technique est appelée maillage. J'ai l'habitude de travailler avec des jeux informatiques et des graphiques 3D, où le monde est construit à partir d'unités et projeté en pixels, j'ai donc été un peu surpris. J'ai découvert que dans le domaine du rendu des polices, c'est un choix très important.

Pour montrer l'importance du maillage, examinons un scénario possible pour la pixellisation des glyphes. Imaginez qu'un glyphe est tramé sur une grille de pixels, mais la forme du glyphe ne correspond pas parfaitement à la structure de la grille:


L'anticrénelage rend les pixels à droite et à gauche du glyphe également gris. Si le glyphe est légèrement décalé pour mieux correspondre aux bordures des pixels, alors un seul pixel sera coloré et il deviendra complètement noir:


Maintenant que le glyphe correspond bien aux pixels, les couleurs sont devenues moins floues. La différence de netteté est très grande. Les polices occidentales ont de nombreux glyphes avec des lignes horizontales et verticales, et si elles ne correspondent pas bien à la grille de pixels, les nuances de gris rendent la police floue. Même la meilleure technique d'anticrénelage n'est pas en mesure de faire face à ce problème.

L'indication de police a été proposée comme solution. Les auteurs de polices doivent ajouter des informations à leurs polices sur la façon dont les glyphes doivent s'accrocher aux pixels s'ils ne correspondent pas parfaitement. Le système de rendu des polices déforme ces courbes pour les accrocher à la grille de pixels. Cela augmente considérablement la clarté de la police, mais a un prix:

  • Les polices deviennent légèrement déformées . Les polices ne semblent pas exactement comme prévu.
  • Tous les glyphes doivent être attachés à la grille de pixels: le début du glyphe et la largeur du glyphe. Il est donc impossible de les animer entre pixels.

Fait intéressant, pour résoudre ce problème, Apple et Microsoft ont opté pour des méthodes différentes. Microsoft adhère à une clarté absolue et Apple cherche à afficher plus précisément les polices. Sur Internet, vous pouvez trouver des gens qui se plaignent des polices floues sur les machines Apple, mais beaucoup de gens aiment ce qu'ils voient sur Apple. C'est en partie une question de goût. Voici le post de Joel sur le logiciel, et voici le post de Peter Bilak sur ce sujet, mais si vous effectuez une recherche sur Internet, vous pouvez trouver beaucoup plus d'informations.

Étant donné que la résolution DPI dans les écrans modernes augmente rapidement, la question se pose de savoir si un indice de police sera nécessaire à l'avenir, comme c'est le cas aujourd'hui. Dans mon état actuel, je trouve que l'indication des polices est une technique très utile pour rendre les polices clairement. Cependant, la technique décrite dans mon article peut devenir une alternative intéressante à l'avenir, car les glyphes peuvent être librement placés sur la toile sans distorsion. Et comme il s'agit essentiellement d'une technique d'anticrénelage, elle peut être utilisée à n'importe quelle fin, et pas seulement pour le rendu des polices.

Enfin, je parlerai brièvement du rendu sous-pixel . Dans le passé, les gens se rendaient compte que vous pouvez tripler la résolution horizontale de l'écran en utilisant les rayons rouges, verts et bleus individuels d'un écran d'ordinateur. Chaque pixel est construit à partir de ces rayons, qui sont physiquement séparés. Notre œil mélange leurs valeurs, créant une couleur d'un seul pixel. Lorsque le glyphe ne couvre qu'une partie du pixel, seul le faisceau qui est superposé au glyphe est activé, ce qui triple la résolution horizontale. Si vous agrandissez l'image à l'écran en utilisant une technique comme ClearType, vous pouvez voir les couleurs autour des bords du glyphe:


Fait intéressant, l'approche que je vais discuter dans l'article peut être étendue au rendu sous-pixel. J'ai déjà implémenté son prototype. Son seul inconvénient est qu'en raison de l'ajout de filtrage dans des techniques comme ClearType, nous devons prendre plus d'échantillons de texture. J'y réfléchirai peut-être à l'avenir.

Rendu de glyphe à l'aide d'une grille uniforme


Supposons que nous échantillonnions un glyphe avec une résolution 16 fois la cible et l'enregistrions dans une texture. Je vais décrire comment cela se fait dans la troisième partie de l'article. Un motif d'échantillonnage est une grille uniforme, c'est-à-dire que 16 points d'échantillonnage sont répartis uniformément sur le texel. Chaque glyphe est rendu avec la même résolution que la résolution cible, nous stockons 16 bits par texel, et chaque bit correspond à un échantillon. Comme nous le verrons dans le processus de calcul du masque de couverture, l'ordre de stockage des échantillons est important. En général, les points d'échantillonnage et leurs positions pour un texel ressemblent à ceci:


Obtenir des texels


Nous déplacerons la fenêtre de pixels par les bits de couverture stockés dans les texels. Nous devons répondre à la question suivante: combien d'échantillons entreront dans notre fenêtre de pixels? Il est illustré par l'image suivante:


Nous voyons ici quatre texels, sur lesquels un glyphe est partiellement superposé. Un pixel (indiqué en bleu) couvre une partie des texels. Nous devons déterminer combien d'échantillons notre fenêtre de pixels traverse. Nous avons d'abord besoin des éléments suivants:

  • Calculez la position relative de la fenêtre de pixels par rapport à 4 texels.
  • Obtenez les texels que notre fenêtre de pixels recoupe.

Notre implémentation est basée sur OpenGL, donc l'origine de l'espace de texture commence en bas à gauche. Commençons par calculer la position relative de la fenêtre de pixels. La coordonnée UV transmise au pixel shader est la coordonnée UV du centre du pixel. En supposant que les UV sont normalisés, nous pouvons d'abord convertir les UV en espace texel en le multipliant par la taille de la texture. En soustrayant 0,5 du centre du pixel, nous obtenons le coin inférieur gauche de la fenêtre de pixel. En arrondissant cette valeur vers le bas, nous calculons la position inférieure gauche du texel inférieur gauche. L'image montre un exemple de ces trois points dans l'espace texel:


La différence entre le coin inférieur gauche du pixel et le coin inférieur gauche de la grille de texels est la position relative de la fenêtre de pixel en coordonnées normalisées. Dans cette image, la position de la fenêtre de pixels sera [0,69, 0,37]. Dans le code:

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


En utilisant l'instruction textureGather, nous pouvons obtenir quatre texels à la fois. Il n'est disponible que dans OpenGL 4.0 et supérieur, vous pouvez donc exécuter quatre texelFetch à la place. Si nous passons juste les coordonnées UV de textureGather, alors avec une correspondance parfaite de la fenêtre de pixels avec le texel, un problème se posera:


Ici, nous voyons trois texels horizontaux avec une fenêtre de pixels (montrée en bleu) correspondant exactement au texel central. Le poids calculé est proche de 1,0, mais textureGather a plutôt choisi les texels centre et droit. La raison en est que les calculs effectués par textureGather peuvent différer légèrement du calcul du poids en virgule flottante. La différence d'arrondi des calculs GPU et des calculs de pondération en virgule flottante entraîne des problèmes autour des centres de pixels.

Pour résoudre ce problème, vous devez vous assurer que les calculs de poids correspondent à l'échantillonnage textureGather. Pour ce faire, nous n'échantillonnerons jamais les centres de pixels et, à la place, nous échantillonnerons toujours au centre de la grille de 2 × 2 texels. À partir de la position inférieure du texel gauche calculée et déjà arrondie, nous ajoutons le texel complet pour arriver au centre de la grille de texels.


Cette image montre qu'en utilisant le centre de la grille de texels, les quatre points d'échantillonnage pris par textureGather seront toujours au centre des texels. Dans le code:

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


Masque horizontal de fenêtre pixel


Nous avons obtenu quatre texels et ensemble ils forment une grille de 8 × 8 bits de couverture. Pour compter les bits dans une fenêtre de pixels, nous devons d'abord réinitialiser les bits en dehors de la fenêtre de pixels. Pour ce faire, nous allons créer un masque de fenêtre de pixels et effectuer un ET au niveau du bit entre le masque de pixels et les masques de couverture de texels. Le masquage horizontal et vertical sont effectués séparément.

Le masque de pixels horizontal doit se déplacer avec le poids horizontal, comme indiqué dans cette animation:


L'image montre un masque 8 bits avec la valeur 0x0F0 se déplaçant vers la droite (des zéros sont insérés à gauche). En animation, un masque est animé linéairement avec du poids, mais en réalité, un décalage de bits est une opération étape par étape. Le masque change de valeur lorsque la fenêtre de pixels traverse la bordure de l'échantillon. Dans l'animation suivante, cela est indiqué dans des colonnes rouges et vertes, animées étape par étape. La valeur ne change que lorsque les centres des échantillons se croisent:


Pour que le masque se déplace uniquement au centre de la cellule, mais pas sur ses bords, un simple arrondi suffit:

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

Nous avons maintenant un masque de pixels d'une chaîne complète de 8 bits couvrant deux texels. Si nous choisissons le bon type de stockage dans notre masque de couverture 16 bits, il existe des moyens de combiner le texel gauche et droit et d'effectuer un masquage horizontal des pixels pour une ligne 8 bits complète à la fois. Cependant, cela devient problématique avec le masquage vertical lorsque nous passons aux grilles tournées. Par conséquent, nous combinons à la place deux texels gauches et séparément deux texels droits pour créer deux masques de couverture 32 bits. Nous masquons les résultats gauche et droit séparément.

Les masques pour les texels gauches utilisent les 4 bits supérieurs du masque de pixels, et les masques pour les texels droits utilisent les 4 bits inférieurs. Dans une grille uniforme, chaque ligne a le même masque horizontal, nous pouvons donc simplement copier le masque pour chaque ligne, après quoi le masque horizontal sera prêt:

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;


Pour masquer, nous combinons deux texels gauches et deux texels droits, puis masquons les lignes horizontales:

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


Maintenant, le résultat peut ressembler à ceci:


Nous pouvons déjà compter les bits de ce résultat en utilisant l'instruction bitCount. Nous ne devons pas diviser par 16, mais par 32, car après le masquage vertical, nous pouvons toujours avoir 32 bits potentiels, et non 16. Voici le rendu complet du glyphe à ce stade:


Ici, nous voyons une lettre agrandie S rendue sur la base des données vectorielles originales (contour blanc) et la visualisation des points d'échantillonnage. Si le point est vert, il se trouve à l'intérieur du glyphe, s'il est rouge, alors non. Niveaux de gris affiche les teintes calculées à ce stade. Dans le processus de rendu des polices, il existe de nombreuses possibilités d’erreurs, allant de la pixellisation, de la façon dont les données sont stockées dans un atlas de texture et du calcul de la teinte finale. Ces visualisations sont incroyablement utiles pour valider les calculs. Ils sont particulièrement importants pour le débogage d'artefacts au niveau sous-pixel.

Masquage vertical


Nous sommes maintenant prêts à masquer les bits verticaux. Pour masquer verticalement, nous utilisons une méthode légèrement différente. Pour gérer le décalage vertical, il est important de se rappeler comment nous avons enregistré les bits: par ordre de rang. La ligne du bas est les quatre bits les moins significatifs et la ligne du haut est les quatre bits les plus significatifs. Nous pouvons simplement nettoyer un par un, en les décalant en fonction de la position verticale de la fenêtre de pixels.

Nous allons créer un seul masque couvrant toute la hauteur de deux texels. En conséquence, nous voulons enregistrer quatre lignes complètes de texels et masquer tout le reste, c'est-à-dire que le masque sera de 4 × 4 bits, ce qui est égal à 0xFFFF. En fonction de la position de la fenêtre de pixels, nous décalons les lignes du bas et effaçons les lignes du haut.

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


En conséquence, nous avons également masqué les bits verticaux en dehors de la fenêtre de pixels:


Maintenant, il nous suffit de compter les bits restants dans les texels, ce qui peut être fait avec l'opération bitCount, puis diviser le résultat par 16 et obtenir la nuance souhaitée!

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

Maintenant, le rendu complet de la lettre ressemble à ceci:


À suivre ...


Dans la deuxième partie, nous passerons à l'étape suivante et verrons comment appliquer cette technique aux grilles tournées. Nous allons calculer ce schéma:


Et nous verrons que presque tout cela peut être réduit à plusieurs tableaux.

Merci à Sebastian Aaltonen ( @SebAaltonen ) pour son aide dans la résolution du problème de textureGather et, bien sûr, à Michael van der Leu ( @MvdleeuwGG ) pour ses idées et conversations intéressantes le soir.

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


All Articles