Text rendering part three : signed distance field
How to write text with freetype, c++ and Vulkan part three : signed distance field
To improve the quality of the text rendering Valve proposed the signed distance field technique. You can find the paper here, it’s a quick read and very interesting.
The idea is to compute for each pixel the distance between the said pixel and a point of reference. In our case we can see the glyph split in two parts: inside the glyph (every pixel inside the border of the glyph) and the outside of the glyph. The distance that will be computed is by how far the pixel is from the nearest inside pixel. Yes, including the pixels already inside. That will generate a positive distance for “far away” pixels and negative distances for “very close / inside” pixels.
It’s fairly simple in reality, specially when like me, you are using the freeType library that just have the option to generate for you the signed distance field. If you want to do the algorithm by yourself (and I encourage you to do it as an exercice) you can check this or the wikipedia page. I implemented the first link myself, then I discovered that freeType can do it... So I know the algorithm is working but I ended up removing what I did in favor of the freeType option.
Anyway, for those who are using freeType the modifications from our first article are pretty quick to do.
Now you have to use the FT_RENDER_MODE_SDF :
FT_Render_Glyph(_face->glyph, FT_RENDER_MODE_SDF)You will have to increase the resolution and size of the glyphs, for example :
FT_Set_Char_Size(_face, 0, 16 * 64, 96 * 4, 96 * 4);Right now I am creating this texture at runtime (bad idea, I should at least save the texture the first time it’s created) so if I increase too much it will be very slow, but the more you increase the better the quality will be.
And as it is a signed distance we are now using int8_t instead of uint8_t :
std::vector<int8_t> r_buffer{};
auto const glyph_width{ _face->glyph->bitmap.width };
auto const glyph_height{ _face->glyph->bitmap.rows };
auto const glyph_pitch{ _face->glyph->bitmap.pitch };
r_buffer.resize(glyph_width * glyph_height, 0x000);
auto buffer = _face->glyph->bitmap.buffer;
int index{ 0 };
for (auto y{ 0 }; y < glyph_height; y++) {
int8_t const * row_buffer = reinterpret_cast<int8_t const *>(buffer) + y * glyph_pitch;
for (auto x{ 0 }; x < glyph_width; x++) {
int8_t sdf = row_buffer[x];
r_buffer[index++] = sdf;
}
}I also changed my texture to be a one grey channel, so I removed the * 4 seen in the previous article (don’t forget to update the Vulkan API side).
The format I choose is VK_FORMAT_R8_UNORM, one channel unsigned norm. Why unsigned as we are using a signed distance field ? That way the value will be normalize for us between 0 and 1, easier than dealing with [-1, 1] values into the fragment shader. If you debug your texture (here a screenshot from RenderDoc) it should look like this :
If you read the Valve’s paper you will have the fragment shader logic done for you.
Here my poor implementation (the glow part is poorly done, I don’t have a texture / mask for it), but you’ll get the idea:
vec4 base_color = vec4(in_color, 1.0);
float dist_alpha_mask = texture(tex_sampler[0], in_tex_coords).r;
vec4 outline_color = vec4(0.0, 0.0, 0.0, 1.0);
float outline_min_value0 = 0.5;
float outline_min_value1 = 0.5;
float outline_max_value0 = 0.55;
float outline_max_value1 = 0.55;
float outline_factor = 1.0;
if(dist_alpha_mask >= outline_min_value0 && dist_alpha_mask <= outline_max_value1) {
if (dist_alpha_mask <= outline_min_value1) {
outline_factor = smoothstep(outline_min_value0, outline_min_value1, dist_alpha_mask);
} else {
outline_factor = smoothstep(outline_max_value1, outline_max_value0, dist_alpha_mask);
}
base_color = mix(outline_color, base_color, outline_factor);
}
//I'll let you manage the option as you like, it's a push constant for me
int soft_edges = 2;
float soft_edge_min = 0.55;
float soft_edge_max = 0.6;
if (soft_edges == 1) {
base_color.a *= smoothstep(soft_edge_min, soft_edge_max, dist_alpha_mask);
} else if (dist_alpha_mask < 0.5) {
base_color.a = 0.0;
}
//I'll let you manage the option as you like, it's a push constant for me
int outer_glow = 1;
if (outer_glow == 1) {
vec4 outer_glow_color = vec4(0.0, 0.0, 1.0, 0.2);
float outer_glow_min_dvalue = 0.40;
float outer_glow_max_dvalue = 0.5;
//the glow part is poorly done, I don't have texture / mask for it
if (dist_alpha_mask >= outer_glow_min_dvalue && dist_alpha_mask <= outer_glow_max_dvalue) {
float outer_glow_factor = smoothstep(outer_glow_min_dvalue, outer_glow_max_dvalue, dist_alpha_mask);
base_color = mix(outer_glow_color, outline_color, outer_glow_factor);
}
}
final_color = base_color;And the wonderful result :
It’s not obivous at first sight on this screenshot, but I assure you the quality is really superior. The letter here is really stretched from its original size and the quality is still good. I am not even using such a big glyph resolution, it can be even better.
Thanks to signed distance field we can now add outer glow, outline and tweak some parameters for differents effects and still have good performances and a low memory footprint !
Pretty neat, thanks Valve !



