Tuesday, June 3, 2014

LIBTTF & OpenGL

This is a tutorial on how to use libttf to render text in OpenGL.
You can find an example with source code here.
Please note that the sample uses my personal engine to a certain degree. I included it as a static library so you can compile the sample for yourself.

Bitmap fonts Vs. TTF fonts

Getting consistent text quality across multiple platforms with different screen resolutions can be a pain in the ass. I recently switched to TTF text rendering instead of good old bitmap fonts. With bitmaps fonts, stretching was inevitable. I used to pick the largest common font size across multiple resolutions, and have it scaled down on small screens. To avoid stretching, I could have maintained font bitmaps of different sizes, one for each resolution, but I didn't even bother because it makes switching fonts more painful (it becomes necessary to regenerate all the bitmaps upon every font change). Plus you never really know the resolutions on mobile devices specially in Android, making it virtually impossible to avoid text stretching with bitmap fonts. Agreed it's not the end of the world when the text is stretched a bit thanks to OpenGL's filtering, but if you're shooting for that pixel-perfect, crisp look, bitmap fonts simply don't cut it.

Another more critical reason is the localization: It's also a pain to maintain different bitmaps for each locale.

With TTF rendering, it's possible to render beautiful, anti-aliased text at run-time, given any font size, and a wide-range of locales if you pick your TTF font carefully.


Preparing the Atlas texture

It's always a good idea to group textures into atlases to minimize draw calls, even more so when rendering text - you really don't want to have a separate texture and draw call for each character! The main idea is to retrieve the character data generated by libttf and append it to a texture - while making sure no two characters are appended twice.

The next bit of code should produce the following atlas for the string "Hello World!". I put way more comments than I usually do just to make clear for you.

256x256 Atlas for "Hello World!"

 std::string text = "Hello World!";  
 Size atlasSize(256, 256); // atlas size in pixels  
 u32 fontSize = 80; // font size in pixels  
 u32 textureDataSize = atlasSize.Width*atlasSize.Height*2; // store 2 bytes per-pixel, alpha & luminance  
 u8* pTextureData = snew u8[textureDataSize];  
 memset(pTextureData, 0, textureDataSize);
 
 // Initialize freetype and load SCRIPTBL.TTF  
 FT_Library ft;  
 FT_Init_FreeType(&ft);  
 if(FT_Init_FreeType(&ft))  
 {  
      SHOOT_ASSERT(false, "FT_Init_FreeType() failed");  
 }  
 FT_Face face;  
 if(FT_New_Face(ft, "data/common/SCRIPTBL.TTF", 0, &face))  
 {  
      SHOOT_ASSERT(false, "FT_New_Face() failed");  
 }  
 FT_Set_Pixel_Sizes(face, 0, fontSize); 
                                
 //! CharData - Needed to build the vertex buffer later on.  
 struct CharData  
 {  
      Vector2 UVMin; // the upper left UV of a character  
      Vector2 UVMax; // the lower right UV of a character  
      Vector2 vSize; // the size of a character in pixels  
      Vector3 vOffset; // the character offset from the baseline in pixels  
      Vector3 vAdvance; // offset to draw the next character, in 64 pixels (divide by 64 to get offset in pixels)  
 }; 
  
 std::map<char, CharData> charMap;  
 Point curOffset;  
 s32 curMaxY = 0;  
 Size padding(1, 1); // add 1 pixel padding between characters, to avoid bleeding  
 for(u32 i=0; i<text.length(); ++i)  
 {  
      char c = text.at(i);  
      if(charMap.find(c) != charMap.end())  
      {  
           continue;  // character was already added
      }  
      if(FT_Load_Char(face, c, FT_LOAD_RENDER))  
      {  
           SHOOT_ASSERT(false, "FT_Load_Char() failed");  
      }      
  
      // This is the most important structure provided by libttf  
      // Contains all the character metric data we need  
      FT_GlyphSlot g = face->glyph;      
  
      if(curOffset.X + g->bitmap.width > atlasSize.Width) // no more room in the horizontal direction  
      {  
           if(curOffset.Y + curMaxY + padding.Height + g->bitmap.rows > atlasSize.Height)  
           { // no more room in the vertical direction  
                SHOOT_WARNING(false, "TextTTF: Texture too small");  
                break;  
           }  
           // reset horizontal offset to 0 and add the size of the biggest character to the vertical offset  
           curOffset.X = 0;  
           curOffset.Y += (curMaxY + padding.Height);  
           curMaxY = 0;  
      }      
  
      // Copy the character bitmap data into our OpenGL texture data  
      for(int y=0; y<g->bitmap.rows; ++y)  
      {  
           for(int x=0; x<g->bitmap.width; ++x)  
           {  
                u8* pixel = &pTextureData[2*(atlasSize.Width*(curOffset.Y + y) + (curOffset.X + x))];  
                // store the same value for both Alpha and Luminance  
                pixel[0] = pixel[1] = g->bitmap.buffer[g->bitmap.width*y + x];  
           }  
      }      
  
      // Record character data, needed to build the vertex buffer later on  
      CharData charData =   
      {  
           Vector2::Create(f32(curOffset.X)/atlasSize.Width, f32(curOffset.Y)/atlasSize.Height),  
           Vector2::Create(f32(curOffset.X+g->bitmap.width)/atlasSize.Width, f32(curOffset.Y+g->bitmap.rows)/atlasSize.Height),  
           Vector2::Create(f32(g->bitmap.width), f32(g->bitmap.rows)),  
           Vector3::Create(f32(g->bitmap_left), -f32(g->bitmap_top), 0.0f),  
           Vector3::Create(f32(g->advance.x >> 6), f32(g->advance.y >> 6), 0.0f)  
      };      
  
      charMap[c] = charData;  
      curOffset.X += (g->bitmap.width + padding.Width);  
      curMaxY = Math::Max(curMaxY, g->bitmap.rows);  
 } 
  
 FT_Done_Face(face);  
 FT_Done_FreeType(ft);            

Uploading the Atlas to OpenGL

Here is how to create an Alpha/Luminance OpenGL texture and upload the Atlas data to it:

 glGenTextures(1, &m_GLTextureID);  
 glBindTexture(GL_TEXTURE_2D, m_GLTextureID);  
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);  
 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, atlasSize.Width, atlasSize.Height, 0, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE, pTextureData);  
 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);  

Preparing the Vertex Buffer

At this point the hard part is already done. Now is just a matter of using the Character data that was collected during the Atlas generation, which contains all the information needed to position the text vertices and give them the right UV coordinates. Here is how I do it:

 // Build the vertices  
 u32 numVertices = text.size()*6; // 6 vertices (2 triangles) per character  
 Vertex3D* pVertices = snew Vertex3D[numVertices];  
 Vector3 vCharacterPos = Vector3::Create(100.0f, 100.0f, 0.0f);  
 for(u32 i=0; i<text.length(); ++i)  
 {  
      CharData& d = charMap[text.at(i)];  
      pVertices[i*6+0].UV = Vector2::Create(d.UVMin.X, d.UVMin.Y);  
      pVertices[i*6+1].UV = Vector2::Create(d.UVMax.X, d.UVMin.Y);  
      pVertices[i*6+2].UV = Vector2::Create(d.UVMax.X, d.UVMax.Y);  
      pVertices[i*6+3].UV = Vector2::Create(d.UVMin.X, d.UVMax.Y);  
      pVertices[i*6+4].UV = Vector2::Create(d.UVMin.X, d.UVMin.Y);  
      pVertices[i*6+5].UV = Vector2::Create(d.UVMax.X, d.UVMax.Y);  
      pVertices[i*6+0].Pos = vCharacterPos + d.vOffset + Vector3::Zero; // Top Left  
      pVertices[i*6+1].Pos = vCharacterPos + d.vOffset + Vector3::Create(d.vSize.X, 0.0f, 0.0f); // Top Right  
      pVertices[i*6+2].Pos = vCharacterPos + d.vOffset + Vector3::Create(d.vSize.X, d.vSize.Y, 0.0f); // Bottom Right  
      pVertices[i*6+3].Pos = vCharacterPos + d.vOffset + Vector3::Create(0.0f, d.vSize.Y, 0.0f); // Bottom Left  
      pVertices[i*6+4].Pos = vCharacterPos + d.vOffset + Vector3::Zero; // Top Left  
      pVertices[i*6+5].Pos = vCharacterPos + d.vOffset + Vector3::Create(d.vSize.X, d.vSize.Y, 0.0f); // Bottom Right  
      vCharacterPos += d.vAdvance;  
 }  

This is pretty much it! There are other important aspects to text rendering that I might talk about in a future post. If you're ever done 2D rendering using a 3D API like OpenGL, there is the infamous pixel-to-texel alignment problem. Basically, if you want ultra crisp text on any resolution, you gotta make sure each pixel on the screen is going to sample the information from exactly one texel in your Atlas. If any blending happens such as a pixel becomes the average of several texels, the text will look blurred. I have a very simple remedy to this: Just prevent any scaling from happening by always setting the View Matrix to Identity, the World Matrix's scaling components to (1, 1, 1), and more importantly, by making sure the vertex positions are always integers. Hopefully more explanation on this in a future post!

You can find an example with source code here.
Please note that the sample uses my personal engine to a certain degree. I included it as a static library so you can compile the sample for yourself.

No comments:

Post a Comment