
In this article we will take a look at the solution of the pixel perfect font problem in Unity as I implemented it for my currently developed game SODHARA. Unity, given that it is a modern tool geared towards high res output, is not particularly friendly to the retro-inspired style of the yesteryear – the script presented in this article is one of the ways in which this hurdle can be overcome.
1 Overview
In SODHARA I use TextMeshPro
text components for everything text related. I use pixel perfect bitmap fonts in which all the glyphs’ sizes should be integer values, and the resolution is low enough so that each “pixel” (or, rather, a constituent square) of each glyph should correspond to a single pixel on screen.
The truth is however that these fonts are not perfect, and the problem is mostly with padding. This means that for the left (or right) aligned text the first glyph will probably be set properly but then the next one will be not a whole pixel away, which will accumulate after a while and present rather unpleasant artefacts. Much bigger problem lies in centered text, since usually not even the first glyph is properly set in such a case. The situation only gets worse as one introduces features such as localization, template strings, and so on.
To get my point across, please consider images 1-4, whereas the odd ones are taken from the editor and the even ones are in-game screenshots.




Whenever a glyph pixel founds itself in a sub-pixel position it will be “rounded” to the closest integer value, which, in case of being around the center of the pixel, means it will appear in two places at once, yielding “chunky” and irregular glyphs (both letters and spaces) all over the text. What’s worse, in case of dynamic text, the glyphs will change width and shape based on their (changing) position, which I find truly horrendous.
The solutions I found online usually boil down to either moving the entire text object to such a sub-pixel position that will “trick” the rounding process and make the text snap into place, or using custom scripts that snap the text to grid. The first solution is problematic when one has to deal with varying text, i.e. when localization is involved, and it is near impossible to achieve with centered text. The second solution seemed more plausible, but none of the users that mentioned shared their scripts. Thus, I decided to write my own.
2 Pixel Perfect Text Enforcer
Usually retro-styled games use a small number fonts – SODHARA in particular uses three, so the first step was to describe all the fonts used in order to have relevant info on each of them in one place. The class I used is shown in listing 1.
public class CharSpacingInfo
{
public bool IsFontMonospaced => _fixedCharWidth > 0;
public int SuggestedWidthFor(float width) => IsFontMonospaced ? _fixedCharWidth : (int)(width / _pixelSize);
public int _pixelSize;
public int _fontSize;
public int _fixedCharWidth;
public int _interCharSpaceWidth;
public int _whitespaceWidth;
public CharSpacingInfo(int pixelSize, int fontSize, int fixedCharWidth, int interCharSpaceWidth, int whitespaceWidth)
{
_pixelSize = pixelSize;
_fontSize = fontSize;
_fixedCharWidth = fixedCharWidth;
_interCharSpaceWidth = interCharSpaceWidth;
_whitespaceWidth = whitespaceWidth;
}
}
Listing 1. The CharSpacingInfo
class keeps tracks of the various font measurements.
The fields of the CharSpacingInfo
class represent the following font qualities:
pixelSize
: size of the “font pixel”, or the “constituent square” as I dubbed it previously,fontSize
: size of the font as set in theTextMeshPro
script within the inspector,fixedCharWidth
: width of the glyphs in monospaced fonts (set to-1
in case the font is not monospaced),interCharSpaceWidth
: width of the space between individual glyphs, and finallywhitespaceWidth
: width of the whitespace, or spacebar character.
Now, the method for updating the text itself is shown in listing 2.
private void UpdateTextSpacing()
{
_isAdjustingTextNow = true;
_rawText = TMP_Text.text.Contains("mspace") ? _rawText : TMP_Text.text.Trim();
var textWithAdjustedSpacing = "";
var textPixelLength = 0;
foreach (char c in _rawText)
{
if (FontAsset.characterLookupTable.TryGetValue(c, out TMP_Character tmpCharacter))
{
var suggestedSize = c == ' ' ?
CurrCharSpacingInfo._whitespaceWidth :
CurrCharSpacingInfo.SuggestedWidthFor(tmpCharacter.glyph.metrics.width);
textWithAdjustedSpacing += CharWithTags(c, suggestedSize);
textPixelLength += suggestedSize + CurrCharSpacingInfo._interCharSpaceWidth;
}
else
{
Debug.LogWarning($"Glyph for character '{c}' not found in the font asset.");
}
}
textPixelLength -= CurrCharSpacingInfo._interCharSpaceWidth;
var shouldNudge = IsTextPosXInt && textPixelLength % 2 != 0; // text has not yet been nudged but it should be
var shouldRevert = !IsTextPosXInt && textPixelLength % 2 == 0; // text has been nudged but should now be pulled back
var adjustment = shouldNudge ? -.5f : (shouldRevert ? +.5f : 0f);
RectTransform.anchoredPosition = new Vector2(Pos.x + adjustment, Pos.y);
TMP_Text.text = textWithAdjustedSpacing;
ForceTextUpdate();
_isAdjustingTextNow = false;
}
Listing 2. The UpdateTextSpacing()
method of the PixelPerfectTextEnforcer
class.
The basic idea is to go through the entire text retreived from the TextMeshPro
component and wrap it in <mspace={size}px>{char}</mspace>
tags and then follow up with a <space={interCharSpaceWidth}px>
tag, thus enforcing the desired glyph width as well as the width of space between the glyphs.
The length of the updated text in pixels is tracked in the textPixelLength
variable. In case the final length is odd and the text is in the default position, that entire object is moved left by .5f
. In case the length is even and the text is already nudged, the position is reset. Finally, text update is forced by fiddling with the word spacing in a coroutine.
The result is responsive pixel perfect text that is suitable for all alignment settings, and for both static and dynamic text alike. How it looks like in the end can be viewed in image 3.
Image 3. Pixel perfect text enforcer.
There is a short interval which the text needs to snap into place. This float value is exposed by the script and can be adjusted in the inspector. One more important thing to note is that the TextMeshPro
object’s RectTransform
component has to be set to integer coordinates for the PixelPerfectTextEnforcer
script to work properly.
The entire script may be viewed on GitHub and freely used.
3 Conclusion
Nasty artefacts can really spoil the illusion of playing a game released a few decades ago, which breaks the immersion and ruins the style which the developer worked so hard to achieve. This simple script handles the TextMeshPro
font problems neatly and effectively, and lets devs concentrate on things more important then nudging the text objects by sub-pixel values around.