Easy text rendering with Vulkan

This post shows an easy way to render vector shapes in Vulkan by using stencils. I use this method to render text in Lever.

Getting this renderer to work should be a good way to get started with text rendering on the Vulkan API. It is the same method as described in the Easy Scalable Text Rendering on the GPU, by Evan Wallace. It turned out to be the easiest to implement from many algorithms due to straightforward use of the rasterizing renderer.

I omit the most details of implementing this on Vulkan and concentrate on the trickiest parts of getting the code to work.

Obtaining the outlines to draw

In order to make this to work, you're going to need the glyph outlines as lines and quadratic beziers. It should be easy to obtain the data from a TTF library.

Here's a contour for a 'comma' -character, of the Source Sans Pro -font:

(x=0.067,  y=-0.17)   (x=0.047, y=-0.122)
(x=0.047,  y=-0.122)  (x=0.09,  y=-0.103) (x=0.114,  y=-0.0715)
(x=0.114,  y=-0.0715) (x=0.138, y=-0.04)  (x=0.137,  y=0.0)
(x=0.137,  y=0.0)     (x=0.134, y=-0.001) (x=0.127,  y=-0.001)
(x=0.127,  y=-0.001)  (x=0.103, y=-0.001) (x=0.0855, y=0.0135)
(x=0.0855, y=0.0135)  (x=0.068, y=0.028)  (x=0.068,  y=0.056)
(x=0.068,  y=0.056)   (x=0.068, y=0.083)  (x=0.086,  y=0.0985)
(x=0.086,  y=0.0985)  (x=0.104, y=0.114)  (x=0.129,  y=0.114)
(x=0.129,  y=0.114)   (x=0.161, y=0.114)  (x=0.1795, y=0.088)
(x=0.1795, y=0.088)   (x=0.198, y=0.062)  (x=0.198,  y=0.017)
(x=0.198,  y=0.017)   (x=0.198, y=-0.048) (x=0.163,  y=-0.096)
(x=0.163,  y=-0.096)  (x=0.128, y=-0.144) (x=0.067,  y=-0.17)

the glyph represented by that data

You should also have some metric data for positioning the glyphs, but the data above should suffice on rendering the glyph.

The requirement for the fillin algorithm to work correctly is that the cutting outlines are winded into a different direction than the filling outlines.

Outline rendering

Once you've obtained the glyph contours, you may first want to check that the data is correct. You can do that by implementing a line renderer.

The outline renderer is also easier to rewrite into a filled renderer with the instructions I am going to provide.

You don't have to turn the quadratic beziers into line form to fill the shapes, but you may like to do it for other purposes. In that case you may like to read this article from the Anti-Grain Geometry website: Adaptive Subdivision of Bezier Curves

Filled rendering

A rasterizer can be used directly on our data if we use stenciling. To do this, generate a triangle fan by winding the contour around a some point that is inside the glyph box. Also generate rectangles that covers the glyph boxes for every letter you draw.

The triangle fan is used to fill the stencil buffer. You can put the front-facing triangles in the fan to increment the stencil value and back-facing triangles to decrement it.

When the triangle fan is rendered, the stencil will be zeroed for the fragments that are outside the shape.

The glyph can be made visible by rendering the rectangle that covers the glyph box when the stencil test is configured to fail on zero.

Vertex format

This is not the minimal vertex format for this purpose, but it gets the job done:

vertex_struct = ffi.struct([
    ["pos",   R32G32B32_SFLOAT],
    ["coord", R32G32B32_SFLOAT],
    ["color", R32G32B32_SFLOAT] ])
vxb0 = VertexBinding(0, vertex_struct, "VERTEX")

vertex_layout = VertexLayout("TRIANGLE_LIST", {
    0: vxb0.pos
    1: vxb0.coord
    2: vxb0.color

The coord is set [0, 0.5, 0] for everything else except the bezier shapes.

At minimum you could use two coordinates for position, one float for coord, then feed the color via instance attributes and use indirect instancing for drawing the glyph shapes.

Pipeline configuration

To render the glyphs correctly, you have to enable stencil testing. The stencil test does a comparison with the value in the stencil buffer for the drawn fragment. If the test fails, the fragment isn't drawn. The "stenciling" is a self-descriptive name for it.

What you have to do here can be represented with following pseudo-code:

if (stencil_value & compareMask) != 0
    new_stencil_value = passOp(stencil_value)
    draw fragment
    new_stencil_value = failOp(stencil_value)
    discard fragment update
stencil_value &= ~writeMask
stencil_value |= new_stencil_value & writeMask

From the above code you should notice that..

The depthStencilState used to configure the pipeline is:

depthStencilState = {
    stencilTestEnable = true
    front = {
        reference  = 0
        #compareMask = dynamic
        compareOp = "NOT_EQUAL"
        failOp = "INCREMENT_AND_WRAP"
        #passOp = "KEEP"
        #depthFailOp = "KEEP"
        #writeMask = dynamic
    back = {
        reference  = 0
        #compareMask = dynamic
        compareOp = "NOT_EQUAL"
        failOp = "DECREMENT_AND_WRAP"
        #passOp = "KEEP"
        #depthFailOp = "KEEP"
        #writeMask = dynamic

The INCREMENT_AND_WRAP and DECREMENT_AND_WRAP are named because there are variations of these that clamp the value instead of wrapping it.

The compareMask, reference, writeMask can be set dynamic. It allows the use of stenciling without multiple pipeline objects. You only need the dynamic masks so set them:

dynamicState = {
    dynamicStates = [

Of course, for the above to work you have to define the stencil attachment for the subpass where you render fonts, allocate a stencil buffer and attach it to your framebuffer for this to work. I suggest to allocate a combined stencil & depth buffer, unless there's a reason to do otherwise.

Drawing commands

Now you can set the compareMask zero and fill the stencilWriteMask to update the stencil buffer. Then you flip the values upside-down to draw the glyph using the stencil.

cbuf.setStencilCompareMask("FRONT_AND_BACK", 0)
cbuf.setStencilWriteMask("FRONT_AND_BACK", 0xFF)
cbuf.draw(vbo.triangle_fan_vertex_count, 1, vbo.first_triangle_fan, 0)
cbuf.setStencilCompareMask("FRONT_AND_BACK", 0xFF)
cbuf.setStencilWriteMask("FRONT_AND_BACK", 0)
cbuf.draw(vbo.glyph_box_vertex_count, 1, vbo.first_glyph_box, 0)

The reason why use 0xFF here should be clear if you have learnt your bitwise operations.

The shaders

Coordinates, the c0 is the control point of a quadratic bezier:

v0 = [0, 0, 0]
c0 = [1, 0, 0]
v1 = [0, 1, 0]

The vertex shader:

#version 450
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shading_language_420pack : enable
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 coord;
layout (location = 2) in vec3 color;

layout (location = 0) out vec2 out_coord;
layout (location = 0) out vec3 out_color;

void main(void)
    out_coord = coord.xy;
    out_color = color;
    gl_Position = vec4(position, 1.0);

The fragment shader:

#version 450
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shading_language_420pack : enable

layout (location = 0) in vec2 coord;
layout (location = 1) in vec3 color;

layout (location = 0) out vec4 frag_color;

void main(void)
    float x = coord.x*0.5 + coord.y;
    if (x*x >= t) discard;
    frag_color = vec4(color, 1.0);

The above shader renders the quadratic beziers by rasterizing them from triangles. This approach doesn't affect the quality any bit compared to evaluating the beziers on the CPU, but it reduces the amount of triangles you have to render by a huge margin while being itself quite simple to implement as you see.

Advantages/deficits of this algorithm

Colored letters, stacked

Using INCREMENT/DECREMENT instead of INVERT in the stencil allows the letters to stack without issues if they happen to be the same color. However, if the letters are differently colored or textured, this approach will mess up the colors because the stencil cannot tell apart the different letters.

To alleviate this, you can draw into a texture and render texturized glyph boxes, but it may mess up the nice and clean edges you see here.

I would propose triangulation of the input data as a good all-around solution here. There's an article from css-tricks that describes a Javascript module for triangulation.

The stenciling to render polygons is a nice shortcut because it will work on a lot of cases, especially as a placeholder, and gives a nice step-ahead into the harder ways to render text.

Playing around with a simple case of stencil testing helps you understand the other use-cases of stenciling. It includes the insertion of shadows using the shadow volumes.

You can read more about the stenciling from the Vulkan specification.

Similar posts