LiteEngine 2025 — OpenGL / C++

Rendering Grid Lines on a Fixed Viewport in OpenGL

Rendering grid lines in OpenGL is almost entirely left to the user. This post details how they are implemented in LiteEngine — from vertex layout through index generation to the render loop.

Data Structures

Vertices are represented by a struct holding a vec3 for position and a vec4 for color. A vec4 for position would be more correct, but since the engine is currently 2D-focused, that refactor can come later. Built on top of that is a Quad class holding four vertices, with the access pattern Quad.vertexN.set/getVPosition().

This abstraction is necessary because there is no concept of a line primitive in computer graphics — a line is just a thin quad. Grid lines are typically static, so once the vertex data is constructed it can be left alone until cleanup.

Index Generation

Generating indices for quads follows a predictable pattern that scales to an arbitrary number of quads. Each quad is composed of two triangles sharing vertices, so the stride between consecutive quads is always 4. The math handles the rest:

Renderer.cpp — genIndicies
std::vector<unsigned int> Renderer::genIndicies(unsigned int maxEntities)
{
    std::vector<unsigned int> indices;
    indices.reserve((size_t)maxEntities * 6);

    Vec3<unsigned int> firstTriangle  = {0, 1, 3};
    Vec3<unsigned int> secondTriangle = {0, 3, 2};
    Vec3<unsigned int> offset         = {4, 4, 4};

    for (unsigned int i = 0; i < maxEntities; i++)
    {
        indices.push_back(firstTriangle.m_x);
        indices.push_back(firstTriangle.m_y);
        indices.push_back(firstTriangle.m_z);
        indices.push_back(secondTriangle.m_x);
        indices.push_back(secondTriangle.m_y);
        indices.push_back(secondTriangle.m_z);
        firstTriangle  += offset;
        secondTriangle += offset;
    }

    return indices;
}

Uploading to the Renderer

Getting quad data to the renderer is straightforward. All quads are packed into a single vertex buffer to minimize draw calls — batching geometry is the right call here. The function takes the quad vector by reference to avoid copying a potentially large dataset.

Renderer.cpp — addGrid
void Renderer::addGrid(std::vector<Quad<float>>& quads)
{
    std::vector<unsigned int> indices = Renderer::genIndicies(quads.size());

    ib     = new IndexBuffer(&indices, indices.size() * sizeof(unsigned int));
    vb     = new VertexBuffer(quads.data(), quads.size() * sizeof(Quad<float>));
    va     = new VertexArray();
    layout = new VertexBufferLayout();

    layout->Push<float>(3); // position
    layout->Push<float>(4); // color
    va->addBuffer(*vb, *layout);
}
Pending: the renderer currently holds a single buffer. The next step is maintaining a list of buffer pointers and incrementing a buffer ID or count on each creation of a buffer. That change is covered in a follow-up post.

Fabricating the Grid Lines

Grid line quads are constructed in NDC space, where both axes run from -1 to 1. For each line, the center position is computed by dividing the current index by the total count and remapping to NDC. The half-width (hw) and half-height (hh) offsets then push vertices outward from that center to form the quad's four corners.

Grid.cpp — fabGridLines
std::vector<Quad<float>> fabGridLines()
{
    float hw = (m_LineWidth / 2.0f) / (float)m_vWidth;
    float hh = (m_LineWidth / 2.0f) / (float)m_vHeight;

    // Vertical lines — sweep along X
    for (float i = 0; i <= m_Col; i++)
    {
        float x = (i / (float)m_Col) * 2.0f - 1.0f;
        Quad<float> lineX;

        lineX.v0.setVPosition(Vec3<float>(x - hw, -1.0f - hh,  1.0f));
        lineX.v1.setVPosition(Vec3<float>(x + hw, -1.0f - hh,  1.0f));
        lineX.v2.setVPosition(Vec3<float>(x - hw,  1.0f + hh,  1.0f));
        lineX.v3.setVPosition(Vec3<float>(x + hw,  1.0f + hh,  1.0f));
        lineX.v0.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        lineX.v1.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        lineX.v2.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        lineX.v3.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        m_Quads.push_back(lineX);
    }

    // Horizontal lines — sweep along Y
    for (float j = 0; j <= m_Row; j++)
    {
        float y = (j / (float)m_Row) * 2.0f - 1.0f;
        Quad<float> lineY;

        lineY.v0.setVPosition(Vec3<float>(-1.0f - hw, y - hh, 1.0f));
        lineY.v1.setVPosition(Vec3<float>( 1.0f + hw, y - hh, 1.0f));
        lineY.v2.setVPosition(Vec3<float>(-1.0f - hw, y + hh, 1.0f));
        lineY.v3.setVPosition(Vec3<float>( 1.0f + hw, y + hh, 1.0f));
        lineY.v0.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        lineY.v1.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        lineY.v2.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        lineY.v3.setVColor(Vec4<float>(0.0f, 0.0f, 0.0f, 1.0f));
        m_Quads.push_back(lineY);
    }

    return m_Quads;
}

Initialization

Wiring everything together is minimal. Construct a Grid, fabricate the lines, hand the quad vector to the renderer:

Scene_CGoL.cpp — init
void Scene_CGoL::init()
{
    Grid g(m_renderer->getWidth(), m_renderer->getHeight(), 8, 2, 0);
    m_quads = g.fabGridLines();
    m_renderer->addGrid(&m_quads);
}
Known limitation: viewport resizing is not yet handled. Vertex positions are computed relative to the initial width and height, so resizing produces non-uniform cells. The fix is a dirty flag on Grid that gets set on resize, triggering a full rebuild of the quad data. This decouples the viewport from the grid at the cost of a CPU-side rebuild on each resize. Left as an exercise for now — will be revisited in a later post.

Render Loop

The scene render function is currently single-buffer. Once the renderer supports multiple buffers, the bind and draw calls move into a loop keyed on VBCount:

Scene_CGoL.cpp — sRender (current)
void Scene_CGoL::sRender()
{
    m_renderer->Clear();
    m_renderer->getVB().Bind();
    m_renderer->DrawElements();
    m_renderer->SwapBuffers();
}
Scene_CGoL.cpp — sRender (multi-buffer)
void Scene_CGoL::sRender()
{
    m_renderer->Clear();
    for (int i = 0; i <= m_renderer->VBCount; i++)
    {
        m_renderer->getVB(i).Bind();
        m_renderer->DrawElements();
    }
    m_renderer->SwapBuffers();
}

VBCount is owned by the renderer and queried each frame, so draw calls scale with the number of active buffers. The failure mode to avoid: allocating a new buffer per object. That negates batching entirely and is worse than the single-buffer approach.