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:
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.
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); }
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.
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:
void Scene_CGoL::init() { Grid g(m_renderer->getWidth(), m_renderer->getHeight(), 8, 2, 0); m_quads = g.fabGridLines(); m_renderer->addGrid(&m_quads); }
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:
void Scene_CGoL::sRender() { m_renderer->Clear(); m_renderer->getVB().Bind(); m_renderer->DrawElements(); m_renderer->SwapBuffers(); }
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.