Jordan Savant # Software Engineer

SFML 2 Depth Buffering

As of SFML 2.0, depth testing is explicitly disabled. Implementing portions of drawable content that use OpenGL Depth Buffering can be a difficult undertaking. This page will demonstrate one method for enabling depth buffering for Vertex Arrays.

Problem

Laurent decided in his mighty wisdom that in a 2D media library, utilizing the Z coordinate or Depth Testing of OpenGL was not a valid need. In theory this is correct, as depth buffering is primarily used for 3D rendering and complicating Vertex data with (x, y, z) components could confuse developers and distract from the simplicity of SFML.

But many applications for 2D gaming require drawing images in varying orders, usually dynamically. For instance, a 2D Isometric game will fake a perspective to the player and therefore will require some objects to be drawn on top of other objects.

In these games, usually these objects are dynamic, meaning they move, and therefore their order of drawing must be changed to reflect their position in the world to the player.

Sorting Objects

Since depth testing is disabled in SFML 2.0 the first workaround is to render all objects in the order they should appear on the screen. This poses problems of sorting lists within your game's update loop.

An example of independently drawn objects: perhaps your game contains a list of soldiers in a 2D Isometric game, these soldiers all maintain their own Sprite and each handle their own rendering. This list of soldiers would need to be sorted using a heuristic that would place the soldiers with a higher Y coordinate at the lower indices. Then the iteration for their draw would put them in correct order.

*Complexity: Nlog2(N) + N + N**, sort plus update loop plus rendering loop.

An example of Vertex Array: since the VertexArray class in SFML does not expose a method for sorting the vertices within, a separate list must be maintained that is equal in size to the VertexArray, this list is sorted, then the list is iterated across and its Vertex data copied into the VertexArray at the same index.

*Complexity: Nlog2(N) + N**, sort plus update loop.

Obviously this adds considerable performance efforts to your update routines.

Multiple Textures

In addition to sorting being the only primary method of draw orders, there is larger problem regarding the rendering of textures.

In OpenGL graphics processing, there is a limit to texture size when binding a texture for usage in rendering. These days that average is 2048x2048 pixels, which though large is not nearly large enough to contain all textures for a single game.

If you combined all of your game's sprites into a series of sprite sheets, which is a common approach, then you must bind each texture and send each associated Vertex Array off for rendering.

For example, if your game rendered hundreds of vehicle types, and the images for each vehicle sprite were split into two sprite sheets, you would need each vehicle's vertex to be part of the Vertex Array associated with the texture that is bound and rendered off. Since your vehicle images are in two sprite sheets, you would need two Vertex Arrays. If you had two Vertex Arrays, you could never sort them as a single list, and therefore half of your vehicles would render on top of the other half of vehicles.

Sorting as a technique for draw ordering is not only harsh on update performance, it makes it impossible to sort any objects rendered that use images from two separate textures.

Solution

Given the huge disadvantage of SFML's order limitations, it is necessary to implement the OpenGL Depth Buffering regardless of SFML removing it. At the same time, it cannot disturb the other rendering used within SFML.

The approach taken to solve this problem is:

  1. Configure OpenGL depth and alpha functions after the sf::RenderWindow is created.
  2. Create a derived class of sf::Vertex called Vertex3 that has a sf::Vector3f position3D property.
  3. Create a new Vertex Array class called Vertex3Array that re-implements sf::VertexArray using Vertex3.
  4. Re-implement the sf::RenderTarget::draw() method within Vertex3Array so that we can render Vertex Arrays in OpenGL using a Z coordinate.
  5. Enable Depth Testing and Alpha Testing before drawing your Vertex3Array objects, then disable afterward.

1. Configuring OpenGL

Where your render window is built, directly afterward:

// Build the render window
renderWindow = new sf::RenderWindow(sf::VideoMode(1280, 720, 32), title, sf::Style::Default);

// Configure depth functions
glDepthMask(GL_TRUE);
glDepthFunc(GL_LEQUAL);
glDepthRange(1.0f, 0.0f); // top = 1, bottom = 0
glAlphaFunc(GL_GREATER, 0.0f);

2. The Vertex3 Class

Here is the derived version of sf::Vertex that provides a new 3 component vector for position which will allow for passing the Z coordinate for depth testing.

class Vertex3 : public sf::Vertex
{
public:

    Vertex3()
        : sf::Vertex(), position3D(0, 0, 0)
    {
    }

    Vertex3(const sf::Vector2f& thePosition)
        : sf::Vertex(thePosition), position3D(0, 0, 0)
    {
    }

    Vertex3(const sf::Vector2f& thePosition, const sf::Color& theColor)
        : sf::Vertex(thePosition, theColor), position3D(0, 0, 0)
    {
    }

    Vertex3(const sf::Vector2f& thePosition, const sf::Vector2f& theTexCoords)
        : sf::Vertex(thePosition, theTexCoords), position3D(0, 0, 0)
    {
    }

    Vertex3(const sf::Vector2f& thePosition, const sf::Color& theColor, const sf::Vector2f& theTexCoords)
        : sf::Vertex(thePosition, theColor, theTexCoords), position3D(0, 0, 0)
    {
    }

    Vertex3(const sf::Vector2f& thePosition, const sf::Color& theColor, const sf::Vector2f& theTexCoords, const sf::Vector3f& thePosition3D)
        : sf::Vertex(thePosition, theColor, theTexCoords), position3D(thePosition3D)
    {
    }

    sf::Vector3f position3D;
};

3. The Vertex3Array Class

Since sf::Vertex is baked into the internal vector used inside of sf::VertexArray, we cannot extend sf::VertexArray but must whole sale re-implement it.

Here is Vertex3Array which re-implements all of sf::VertexArray but uses our Vertex3 class. Also, the draw() method was removed for step 4.

class Vertex3Array : public sf::Drawable
{
public:
    Vertex3Array()
        : m_vertices(), m_primitiveType(sf::Points)
    {
    }

    Vertex3Array(sf::PrimitiveType type, unsigned int vertexCount)
        : m_vertices(vertexCount), m_primitiveType(type)
    {
    }

    unsigned int Vertex3Array::getVertexCount() const
    {
        return static_cast<unsigned int>(m_vertices.size());
    }

    Vertex3& operator [](unsigned int index)
    {
        return m_vertices[index];
    }

    const Vertex3& operator [](unsigned int index) const
    {
        return m_vertices[index];
    }

    void clear()
    {
        m_vertices.clear();
    }

    void resize(unsigned int vertexCount)
    {
        m_vertices.resize(vertexCount);
    }

    void append(const Vertex3& vertex)
    {
        m_vertices.push_back(vertex);
    }

    void setPrimitiveType(sf::PrimitiveType type)
    {
        m_primitiveType = type;
    }

    sf::PrimitiveType getPrimitiveType() const
    {
        return m_primitiveType;
    }

    sf::FloatRect getBounds() const
    {
        if (!m_vertices.empty())
        {
            float left   = m_vertices[0].position3D.x;
            float top    = m_vertices[0].position3D.y;
            float right  = m_vertices[0].position3D.x;
            float bottom = m_vertices[0].position3D.y;

            for (std::size_t i = 1; i < m_vertices.size(); ++i)
            {
                sf::Vector3f position = m_vertices[i].position3D;

                // Update left and right
                if (position.x < left)
                    left = position.x;
                else if (position.x > right)
                    right = position.x;

                // Update top and bottom
                if (position.y < top)
                    top = position.y;
                else if (position.y > bottom)
                    bottom = position.y;
            }

            return sf::FloatRect(left, top, right - left, bottom - top);
        }
        else
        {
            // Array is empty
            return sf::FloatRect();
        }
    }

private:
    std::vector<Vertex3> m_vertices;
    sf::PrimitiveType m_primitiveType;
};

4. Adding RenderTarget Draw

In SFML, sf::RenderTarget handles the actual drawing of Vertex Arrays for OpenGL. Since the sf::Vertex class is coded with a 2 dimensional position, the vertices pointer function for OpenGL is hard coded with a 2 for number of coordinates in a single vertex.

const char* data = reinterpret_cast<const char*>(vertices);
glCheck(glVertexPointer(2, GL_FLOAT, sizeof(Vertex), data + 0));
glCheck(glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex), data + 8));
glCheck(glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex), data + 12));

With this method not being virtual for sf::RenderTarget, it must be re-implemented completely, and its simplest enough to do so in the Vertex3Array class.

The normal sf::VertexArray draw is such:

void VertexArray::draw(RenderTarget& target, RenderStates states) const
{
    if (!m_vertices.empty())
        target.draw(&m_vertices[0], static_cast<unsigned int>(m_vertices.size()), m_primitiveType, states);
}

You can see from this code that when the internal draw of VertexArray simply calls the primary draw of RenderTarget.

Here is a completely re-implemented draw for Vertex3Array that supports blending, view transforms, textures, shaders, without the caching done by SFML but working with Vertex3 and completely depth testable by OpenGL.

// Re-implementation of sf::RenderTarget::draw() to allow Depth Testing
void draw(sf::RenderTarget& target, sf::RenderStates states) const
{
    // If no vertices, do not render
    if(m_vertices.empty())
        return;

    // Get vertices data
    const Vertex3* vertices = &m_vertices[0];
    unsigned int vertexCount = static_cast<unsigned int>(m_vertices.size());

    // Apply the transform
    glLoadMatrixf(states.transform.getMatrix());

    // Apply the view
    applyCurrentView(target);

    // Apply the blend mode
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

    // Apply the texture
    sf::Texture::bind(states.texture, sf::Texture::Pixels);

    // Apply the shader
    if (states.shader)
        sf::Shader::bind(states.shader);

    // Setup the pointers to the vertices' components
    if (vertices)
    {
        const char* data = reinterpret_cast<const char*>(vertices);
        glVertexPointer(3, GL_FLOAT, sizeof(Vertex3), data + 20);
        glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex3), data + 8);
        glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex3), data + 12);
    }

    // Find the OpenGL primitive type
    static const GLenum modes[] = {GL_POINTS, GL_LINES, GL_LINE_STRIP, GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN, GL_QUADS};
    GLenum mode = modes[m_primitiveType];

    // Draw the primitives
    glDrawArrays(mode, 0, vertexCount);

    // Unbind the shader, if any
    if (states.shader)
        sf::Shader::bind(NULL);
}

void applyCurrentView(sf::RenderTarget& target) const
{
    // Set the viewport
    sf::IntRect viewport = getViewport(target, target.getView());
    int top = target.getSize().y - (viewport.top + viewport.height);
    (glViewport(viewport.left, top, viewport.width, viewport.height));

    // Set the projection matrix
    (glMatrixMode(GL_PROJECTION));
    (glLoadMatrixf(target.getView().getTransform().getMatrix()));

    // Go back to model-view mode
    (glMatrixMode(GL_MODELVIEW));
}

sf::IntRect getViewport(sf::RenderTarget& target, const sf::View& view) const
{
    float width  = static_cast<float>(target.getSize().x);
    float height = static_cast<float>(target.getSize().y);
    const sf::FloatRect& viewport = view.getViewport();

    return sf::IntRect(
        static_cast<int>(0.5f + width  * viewport.left),
        static_cast<int>(0.5f + height * viewport.top),
        static_cast<int>(width  * viewport.width),
        static_cast<int>(height * viewport.height));
}

Play close attention to the most critical part of the new drawing function:

const char* data = reinterpret_cast<const char*>(vertices);
glVertexPointer(3, GL_FLOAT, sizeof(Vertex3), data + 20);
glColorPointer(4, GL_UNSIGNED_BYTE, sizeof(Vertex3), data + 8);
glTexCoordPointer(2, GL_FLOAT, sizeof(Vertex3), data + 12);

Notice that the 2 is now a 3, so that OpenGL expects an x, y, and z for each vertex and that the data "stride" is at an additional 20 bytes. 20 bytes is calculated by:

  • A float is 32 bits, or 4 bytes.
  • sf::Vertex::position is an sf::Vector2f, which is 2 floats which is 8 bytes.
  • sf::Vertex::color is an sf::Color which has four sf::Uint8 properties (r, b, g, a) for 4 bytes.
  • sf::Vertex::texCoord is an sf::Vector2f, which is 2 floats which is 8 bytes.
  • 8 + 4 + 8 = 20 bytes, where are sf::Vector3 begins for our x, y, and z coordinates for our vertices.

5. Enabling and Disabling Depth Testing

Since our functions were configured for depth testing but not enabled, we must enable them before drawing any Vertex3Array objects. Also, these must be disabled after they are drawn so that other SFML objects are rendered correctly.

Here is an example draw method of an object that renders an internal Vertex3Array called "vertices". The draw method is part of a class that itself implements sf::Drawable so that it can be "drawn" by SFML.

void GameMap::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
    // Override SFML OpenGL Configurations
    glEnable(GL_DEPTH_TEST);
    glEnable(GL_ALPHA_TEST);

    // apply the transform
    states.transform *= getTransform();

    // apply the tileset texture
    states.texture = &this->texture_spritesheet_01;

    // draw the vertex arrays z-sorted
    target.draw(vertices, states);

    glDisable(GL_DEPTH_TEST);
    glDisable(GL_ALPHA_TEST);
}

Summary

When all of the solution's pieces are combined, depth buffering is enabled using OpenGL.

It then becomes a simple requirement to assign a proper Z coordinate value to your vertices for your game objects. Using the example 2D isometric game, the Y position of each unit in the game could be turned into a ratio compared to the screen's height, which is a floating point value between 0 and 1.

Use this value as your Z coordinate and your units will be sorted by the GPU and not by manually sorting them in the CPU.

Final Complexity: N, sorting is removed and only the update loop remain.