Last modified: January 24, 2026
This article is written in: πΊπΈ
Picture this: you've got thousands of soldiers on screen, each with unique armor, weapons, and animations. Grass is swaying, rivers are flowing, and you need all of this running at 60 frames per second. How do you pull that off without your GPU catching fire?
This is the story of how Standard of Iron takes game state and turns it into pixels. We'll walk through the whole journey, from the moment Qt creates an OpenGL window to the final draw calls that paint soldiers on screen.
We'll start with the big picture of how data flows through the system, then dig into each layer: how Qt bootstraps OpenGL, how we record what needs to be drawn, how the backend executes those commands efficiently, where OpenGL actually lives in the code, how different nations get their unique visual styles, and finally how our shaders generate infinite detail without eating all your VRAM.
The renderer works like a recording studio. In the first phase, we record: game logic tells us "there are 5000 soldiers here, some trees over there, a river running through." The SceneRenderer listens to all of this and writes down lightweight commands into something called a DrawQueue. No actual OpenGL happens yetβwe're just taking notes.
In the second phase, we play it back. We sort all those commands by material, shader, and transparency so that similar things get drawn together. Then the Backend walks through that sorted list and actually talks to the GPU. This separation is the key insight that makes everything else work. By splitting "what to draw" from "how to draw it," we can sort for optimal GPU performance, we can record frame N+1 while the GPU is still rendering frame N, and we can test our rendering logic without needing OpenGL at all.
Here's how a single frame flows through the system:
βββββββββββββββββββββββββββββββββββββββ
β Qt Render Thread β
β (creates OpenGL 3.3 Core context) β
ββββββββββββββββ¬βββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 1: RECORDING β
β β
β βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββββ β
β β GameEngine βββββββΆβ SceneRenderer βββββββΆβ Entity Renderers β β
β β ::render() β β ::begin_frame() β β (spearman, archer, β β
β βββββββββββββββ ββββββββββββββββββββ β terrain, trees...) β β
β βββββββββββββ¬ββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββ β
β β DrawQueue β β
β β (just data, no GL) β β
β β β β
β β β’ MeshCmd β β
β β β’ CylinderCmd β β
β β β’ TerrainChunkCmd β β
β β β’ GrassBatchCmd β β
β β β’ 20+ more types... β β
β βββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 2: PLAYBACK β
β β
β ββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββββ β
β β SceneRenderer ββββββββββΆβ Backend β β
β β ::end_frame() β sort β ::execute() β β
β β (sorts queue, β then β β β
β β swaps buffer) β hand β Dispatches to specialized pipelines: β β
β ββββββββββββββββββββ off β β β
β β βββββββββββββββ βββββββββββββββββββ β β
β β β Cylinder β β Vegetation β β β
β β β Pipeline β β Pipeline β β β
β β βββββββββββββββ βββββββββββββββββββ β β
β β βββββββββββββββ βββββββββββββββββββ β β
β β β Terrain β β Character β β β
β β β Pipeline β β Pipeline β β β
β β βββββββββββββββ βββββββββββββββββββ β β
β β βββββββββββββββ βββββββββββββββββββ β β
β β β Effects β β Mesh β β β
β β β Pipeline β β Instancing β β β
β β βββββββββββββββ βββββββββββββββββββ β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββ β
β β OpenGL Draw Calls β β
β β glDrawElements(...) β β
β β glDrawElementsInstancedβ β
β βββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Framebuffer β
β (presented by Qt) β
βββββββββββββββββββββββββ
The key files in this flow are scene_renderer.cpp for the recording phase and backend.cpp for playback.
Our 3D view lives inside a QML interface. Qt Quick provides something called QQuickFramebufferObject that handles all the threading complexity of running OpenGL alongside a declarative UI. We subclass it in gl_view.cpp to hook in our renderer.
The relationship between Qt and our rendering code looks like this:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β QML Layer β
β β
β main.qml β
β βββ GLView { β
β id: viewport β
β engine: gameEngine ββββ binds to GameEngine instance β
β } β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β Qt creates FBO, calls createRenderer()
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β GLView : QQuickFramebufferObject [ui/gl_view.h] β
β β
β βββ GLRenderer : QQuickFramebufferObject::Renderer β
β β β
β βββ render() ββββββββββββΆ GameEngine::render() β
β β β
β βββ createFramebufferObject() β
β β β
β ββββΆ Creates FBO with depth attachment β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
When Qt's render thread starts up, it creates an OpenGL 3.3 Core context for us. Our GLView class notices this and creates a GLRenderer that holds a pointer to the GameEngine. From then on, every frame Qt calls our render method, which calls into GameEngine, which kicks off the whole pipeline.
The actual creation happens in gl_view.cpp around line 30:
auto GLView::createRenderer() const -> QQuickFramebufferObject::Renderer * {
QOpenGLContext *ctx = QOpenGLContext::currentContext();
if ((ctx == nullptr) || !ctx->isValid()) {
qCritical() << "GLView::createRenderer() - No valid OpenGL context";
return nullptr;
}
return new GLRenderer(m_engine);
}
We picked OpenGL 3.3 Core as a balance between running on older hardware and having modern features like instancing. The Core profile means we don't have any of the legacy fixed-function baggageβeverything goes through shaders.
If you're ever debugging why nothing renders, the first place to check is whether the OpenGL context is actually valid. The code logs a warning if there's no context available, which usually means you're running in software mode where 3D won't work. Look for "No valid OpenGL context" in your logs.
Here's the problem with naive rendering: imagine you have 10,000 entities with different meshes, textures, and shaders. If you draw them in whatever order the game logic hands them to you, you'll be constantly switching GPU state. Bind shader A, draw one mesh, bind shader B, draw one mesh, bind shader A again... Each state change costs about a microsecond, and 10,000 of them means 10 milliseconds gone just on switching. At 60 FPS you only have 16ms per frame, so you've already burned most of your budget on bookkeeping.
The solution is to record everything first, then sort it, then draw in the optimal order. That's what the DrawQueue is for. It's essentially a big list of command structsβthings like "draw this mesh with this transform and this color" or "draw a cylinder from here to there." Each command is tiny, maybe 50-100 bytes, and contains no OpenGL calls. Just data.
The commands are defined in draw_queue.h. Here's what a mesh command looks like:
struct MeshCmd {
Mesh *mesh = nullptr;
Texture *texture = nullptr;
QMatrix4x4 model;
QMatrix4x4 mvp;
QVector3D color{1, 1, 1};
float alpha = 1.0F;
int material_id = 0;
Shader *shader = nullptr;
};
There are over 20 command types: CylinderCmd for debug lines and spear shafts, TerrainChunkCmd for ground tiles, GrassBatchCmd for instanced vegetation, HealingBeamCmd for visual effects, and so on. They're all stored in a std::variant so the queue can hold any mix of them.
The SceneRenderer implements an interface called ISubmitter that entity renderers use to submit their draw requests. This interface is defined in submitter.h:
class ISubmitter {
public:
virtual void mesh(Mesh *mesh, const QMatrix4x4 &model, const QVector3D &color,
Texture *tex = nullptr, float alpha = 1.0F,
int material_id = 0) = 0;
virtual void cylinder(const QVector3D &start, const QVector3D &end,
float radius, const QVector3D &color, float alpha = 1.0F) = 0;
virtual void selection_ring(const QMatrix4x4 &model, float alpha_inner,
float alpha_outer, const QVector3D &color) = 0;
// ... about 15 more methods for different visual elements
};
When a Carthaginian spearman renderer wants to draw a torso, it calls the mesh method on the submitter. That method just packs the parameters into a MeshCmd struct and pushes it onto the queue. Fast and simple.
We use double-buffering on these queues. While the GPU is busy rendering the previous frame's queue, the CPU is filling up the next frame's queue. The swap happens in scene_renderer.cpp at the frame boundary:
void Renderer::end_frame() {
if (m_paused.load()) {
return;
}
if (m_backend && (m_camera != nullptr)) {
std::swap(m_fill_queue_index, m_render_queue_index);
DrawQueue &render_queue = m_queues[m_render_queue_index];
render_queue.sort_for_batching();
m_backend->set_animation_time(m_accumulated_time);
m_backend->execute(render_queue, *m_camera);
}
}
We swap pointersβthe GPU gets the fresh queue, and we start recording into the now-empty old one. No locks needed because CPU and GPU never touch the same queue at the same time.
Before we hand the queue to the backend, we sort it. The sorting has a few priorities:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SORTING PRIORITY β
β β
β 1. Opaque objects first, transparent objects last β
β (transparent needs back-to-front order for correct blending) β
β β
β 2. Within opaque: group by shader β
β (switching shader programs is expensive) β
β β
β 3. Within same shader: group by texture β
β (texture binds are moderately expensive) β
β β
β 4. Within same texture: group by mesh β
β (enables instancing - draw 1000 trees in 1 call) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Before sorting: After sorting:
ββββββββββββββββββββββββββ ββββββββββββββββββββββββββ
β soldier (shader A) β β soldier (shader A) β
β tree (shader B) β β soldier (shader A) β
β soldier (shader A) β β soldier (shader A) β
β grass (shader C) β ββββΆ β tree (shader B) β
β soldier (shader A) β β tree (shader B) β
β tree (shader B) β β grass (shader C) β
β river (transparent) β β river (transparent) β
ββββββββββββββββββββββββββ ββββββββββββββββββββββββββ
7 state changes 3 state changes
This sorting pass is what transforms a random pile of draw requests into something the GPU can chew through efficiently. The difference between sorted and unsorted can easily be 2-3x in frame time.
The Backend class in backend.cpp is where OpenGL finally gets involved. It inherits from QOpenGLFunctions_3_3_Core, which gives it access to all the GL functions without polluting the global namespace.
Rather than having one giant loop that handles every command type, we split things into specialized pipelines:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Backend Pipelines β
β β
β βββββββββββββββββββββββ βββββββββββββββββββββββ ββββββββββββββββββββ β
β β CylinderPipeline β β VegetationPipeline β β TerrainPipeline β β
β β β β β β β β
β β β’ spear shafts β β β’ instanced grass β β β’ ground chunks β β
β β β’ debug lines β β β’ trees (pine, β β β’ roads β β
β β β’ selection rings β β olive) β β β’ riverbeds β β
β βββββββββββββββββββββββ β β’ plants β ββββββββββββββββββββ β
β βββββββββββββββββββββββ β
β βββββββββββββββββββββββ βββββββββββββββββββββββ ββββββββββββββββββββ β
β β CharacterPipeline β β EffectsPipeline β β BannerPipeline β β
β β β β β β β β
β β β’ humanoid bodies β β β’ healing beams β β β’ unit banners β β
β β β’ horses β β β’ combat dust β β β’ flags β β
β β β’ elephants β β β’ rain β β β β
β βββββββββββββββββββββββ β β’ auras β ββββββββββββββββββββ β
β βββββββββββββββββββββββ β
β βββββββββββββββββββββββ βββββββββββββββββββββββ β
β β WaterPipeline β β MeshInstancingPipe β β
β β β β β β
β β β’ rivers β β β’ batched meshes β β
β β β’ riverbanks β β β’ buildings β β
β βββββββββββββββββββββββ βββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each pipeline understands the specific needs of its command type and can optimize accordingly. The main execute loop walks through the sorted queue and delegates to the appropriate pipeline. Here's a simplified view from backend.cpp:
void Backend::execute(const DrawQueue &queue, const Camera &cam) {
const QMatrix4x4 view_proj = cam.get_projection_matrix() * cam.get_view_matrix();
const std::size_t count = queue.size();
std::size_t i = 0;
while (i < count) {
const auto &cmd = queue.get_sorted(i);
switch (cmd.index()) {
case CylinderCmdIndex: {
// Batch all consecutive cylinders together
m_cylinderPipeline->m_cylinderScratch.clear();
do {
const auto &cy = std::get<CylinderCmdIndex>(queue.get_sorted(i));
// ... pack into instance buffer ...
++i;
} while (i < count && queue.get_sorted(i).index() == CylinderCmdIndex);
// Draw all cylinders in one instanced call
m_cylinderPipeline->draw_cylinders(instance_count);
continue;
}
// ... handle other command types ...
}
}
}
When it hits a run of cylinder commands, it collects them all into a scratch buffer and draws them all in one instanced call. This is where the earlier sorting pays offβsimilar commands cluster together so batching opportunities are easy to spot. Drawing 1000 cylinders individually would be 1000 draw calls. Instanced, it's just 1.
For managing OpenGL state, we use RAII wrappers defined in state_scopes.h. There's a DepthMaskScope that saves the current depth write setting, applies a new one, and restores the old one when it goes out of scope:
struct DepthMaskScope {
GLboolean prev;
DepthMaskScope(bool enableWrite) {
glGetBooleanv(GL_DEPTH_WRITEMASK, &prev);
glDepthMask(enableWrite ? GL_TRUE : GL_FALSE);
}
~DepthMaskScope() { glDepthMask(prev); }
};
Same pattern for blending, depth testing, polygon offset. This prevents the classic bug where you disable depth writes for some transparent effect and forget to turn them back on, breaking everything that draws afterward.
All the low-level OpenGL code is concentrated in the render/gl folder:
render/gl/
βββ backend.cpp/.h # Main command executor, pipeline coordinator
βββ mesh.cpp/.h # VAO/VBO/EBO wrapper
βββ shader.cpp/.h # GLSL program wrapper with uniform caching
βββ texture.cpp/.h # Texture loading and binding
βββ buffer.cpp/.h # Generic buffer abstraction
βββ camera.cpp/.h # View/projection matrices
βββ resources.cpp/.h # Built-in meshes (quad, cube, cylinder)
βββ shader_cache.cpp/.h # Loads and caches shader programs
βββ state_scopes.h # RAII wrappers for GL state
βββ persistent_buffer.h # Persistent mapped buffers for streaming
βββ backend/ # Individual pipeline implementations
βββ cylinder_pipeline.cpp/.h
βββ terrain_pipeline.cpp/.h
βββ vegetation_pipeline.cpp/.h
βββ ...
Every class that touches OpenGL inherits from QOpenGLFunctions_3_3_Core. This is Qt's way of giving you function pointers to OpenGL without relying on a global loader:
class Mesh : protected QOpenGLFunctions_3_3_Core { ... };
class Shader : protected QOpenGLFunctions_3_3_Core { ... };
class Backend : protected QOpenGLFunctions_3_3_Core { ... };
The Mesh class in mesh.cpp wraps VAOs, VBOs, and index buffers. You give it vertex data and indices, and it lazily uploads them to the GPU on first draw:
void Mesh::draw() {
if (!prepare_draw("Mesh::draw")) {
return;
}
glDrawElements(GL_TRIANGLES, static_cast<GLsizei>(m_indices.size()),
GL_UNSIGNED_INT, nullptr);
m_vao->unbind();
}
void Mesh::draw_instanced(std::size_t instance_count) {
if (instance_count == 0) {
return;
}
if (!prepare_draw("Mesh::draw_instanced")) {
return;
}
glDrawElementsInstanced(GL_TRIANGLES, static_cast<GLsizei>(m_indices.size()),
GL_UNSIGNED_INT, nullptr,
static_cast<GLsizei>(instance_count));
m_vao->unbind();
}
The Shader class in shader.h wraps GLSL programs and caches uniform locations. Looking up a uniform location is a string hash operation on the GPU driver sideβnot catastrophically slow, but slow enough that you don't want to do it every frame for every uniform. So we cache:
class Shader : protected QOpenGLFunctions_3_3_Core {
GLuint m_program = 0;
std::unordered_map<std::string, UniformHandle> m_uniform_cache;
// Cached lookup - fast path after first access
auto uniform_handle(const char *name) -> UniformHandle;
// Set uniforms by cached handle (fast) or by name (convenience)
void set_uniform(UniformHandle handle, const QMatrix4x4 &value);
void set_uniform(const char *name, const QMatrix4x4 &value);
};
The rest of the rendering code doesn't call OpenGL directly. It talks through these abstractions, which means we could theoretically swap backends someday (though OpenGL is deeply baked in, so this is more of an architectural nicety than a real possibility).
We use a fairly conservative subset of OpenGL 3.3:
| What we use | Why |
| Vertex arrays (VAO) | Group vertex attribute state |
| Instanced rendering | Draw 1000 trees in 1 call |
| Depth testing | Hidden surface removal |
| Alpha blending | Transparent effects |
| Polygon offset | Fix z-fighting on terrain |
| GLSL 330 shaders | All visual computation |
What we don't use: geometry shaders (compatibility issues on some drivers), compute shaders (require OpenGL 4.3), tessellation (not needed for our art style), multi-draw indirect (instancing is enough).
Roman legionaries wear red cloaks and carry rectangular shields. Carthaginian infantry have purple tunics and round shields. The underlying skeleton is the same, but the visual details differ. We handle this with a renderer hierarchy.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Humanoid Renderer Hierarchy β
β β
β βββββββββββββββββββββββββββ β
β β HumanoidRendererBase β [humanoid/rig.h] β
β β β β
β β β’ compute_pose() β βββ shared animation logic β
β β β’ draw_common_body() β βββ shared body rendering β
β β β’ render() β βββ orchestrates everything β
β β β β
β β virtual: β β
β β β’ get_variant() β βββ colors, equipment β
β β β’ draw_armor() β βββ nation-specific armor β
β β β’ draw_helmet() β βββ nation-specific helmet β
β βββββββββββββ¬ββββββββββββββ β
β β β
β βββββββββββββββββββΌββββββββββββββββββ β
β β β β β
β βΌ βΌ βΌ β
β βββββββββββββββββββββ βββββββββββββββββ βββββββββββββββββ β
β β Carthage β β Roman β β (future β β
β β SpearmanRenderer β β Spearman... β β nations) β β
β β β β β β β β
β β purple tunics β β red cloaks β β β β
β β round shields β β rectangular β β β β
β β bronze helmets β β steel helms β β β β
β βββββββββββββββββββββ βββββββββββββββββ βββββββββββββββββ β
β β
β Located in: render/entity/nations/carthage/ β
β render/entity/nations/roman/ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The base class HumanoidRendererBase in humanoid/rig.h handles everything that's common to all humanoids: computing the pose from animation state, drawing the basic body parts, coordinating the rendering sequence. But it has virtual methods for the nation-specific bits.
Each nation has derived classes that override these methods. Looking at the Carthaginian spearman in spearman_renderer.cpp, you'll see it sets up purple tunics, bronze helmets, and the distinctive Carthaginian visual style.
The entity system stores a unit type string like "spearman_carthage" on each unit. The EntityRendererRegistry in registry.cpp maps these strings to renderer functions. When it's time to draw, we look up the right renderer and call it. If a unit type isn't registered, it just doesn't renderβthat's usually the first thing to check when soldiers are mysteriously invisible.
Each nation also gets its own shader files. You can see the pattern in the shader lookup:
auto lookup_spearman_shader_resources(const QString &shader_key)
-> std::optional<SpearmanShaderResourcePaths> {
if (shader_key == QStringLiteral("spearman_carthage")) {
return SpearmanShaderResourcePaths{
QStringLiteral(":/assets/shaders/spearman_carthage.vert"),
QStringLiteral(":/assets/shaders/spearman_carthage.frag")};
}
if (shader_key == QStringLiteral("spearman_roman_republic")) {
return SpearmanShaderResourcePaths{
QStringLiteral(":/assets/shaders/spearman_roman_republic.vert"),
QStringLiteral(":/assets/shaders/spearman_roman_republic.frag")};
}
return std::nullopt;
}
The Carthage spearman shader knows how to render bronze with appropriate patina. The Roman shader knows how to render steel with rust patterns. This per-nation customization extends all the way down to the GPU.
Here's a memory problem: if 5000 soldiers each need unique 4K textures for their rust, dirt, and wear patterns, that's around 80 gigabytes of VRAM. Obviously impossible. So instead we generate all that detail procedurally in the shader.
The shaders in assets/shaders use hash functions and noise to create variation. Looking at spearman_carthage.frag, you'll see the building blocks:
// Hash function - turns any position into a pseudo-random number
float hash2(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
// Multi-octave noise - combines multiple frequencies for natural-looking patterns
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
mat2 rot = mat2(0.87, 0.50, -0.50, 0.87);
for (int i = 0; i < 5; ++i) {
v += a * noise(p);
p = rot * p * 2.0 + vec2(100.0);
a *= 0.5;
}
return v;
}
Each soldier's world position plus a random seed produces unique wear patterns. High-frequency noise creates scratches. Low-frequency noise creates larger rust patches. Sine waves create fabric weave patterns. All of this costs some GPU compute time but zero extra memory.
A typical fragment shader checks the material ID to know what kind of surface it's shading:
if (u_materialId == 2) { // Metal armor
// Procedural rust based on world position
float rust = fbm(v_worldPos.xz * 10.0);
vec3 rustColor = vec3(0.5, 0.3, 0.1);
baseColor = mix(baseColor, rustColor, rust * 0.3);
}
else if (u_materialId == 1) { // Cloth
// Fabric weave pattern
vec2 uv = v_worldPos.xz * 50.0;
float weave = sin(uv.x) * sin(uv.y) * 0.1 + 0.9;
baseColor *= weave;
}
The vertex shader sometimes does geometry modifications too. For example, in spearman_carthage.vert, shields get a curved surface:
if (u_materialId == 4) { // Shield
float curveRadius = 0.52;
float curveAmount = 0.46;
float angle = position.x * curveAmount;
float curved_x = sin(angle) * curveRadius;
float curved_z = position.z + (1.0 - cos(angle)) * curveRadius;
position = vec3(curved_x, position.y, curved_z);
}
We have about 90 shader files in the assets/shaders folder, covering everything from terrain and rivers to individual unit types and special effects. The ShaderCache in shader_cache.cpp loads them on demand and keeps them around so we don't recompile every frame.
When nothing renders at all and you're just seeing a black screen, walk through this checklist:
Check if the OpenGL context is valid. Look for "No valid OpenGL context" in logs. If you see it, you're probably running in software mode.
Check if shaders compiled. The shader loading code in shader.cpp logs errors, but you might want to add more verbose output.
Put a breakpoint in DrawQueue::submit to see if anything's actually being recorded. If the queue is empty, the problem is in the game logic, not the renderer.
Check the camera. Entities might be outside the view frustum. Print out the camera's position and view matrix.
Make sure the depth function isn't backwards. GL_GREATER instead of GL_LESS will flip everything.
When performance tanks, it's usually one of three things:
Draw call explosion means batching isn't working. Check if you're using draw_instanced where you should be. A single non-instanced draw where instancing should happen can fragment your batches.
State thrashing means commands aren't sorted properly. Fire up RenderDoc or Nsight and look at the call sequence. If you see shader/texture binds alternating rapidly, the sort isn't working.
Vertex bloat means meshes are too detailed for how small they appear on screen. This points to the LOD system in rig.hβcheck the distance thresholds.
When specific units don't render but debug shapes do, the renderer probably isn't registered. Check entity/registry.cpp and make sure there's a registration call for that unit type. Missing registrations are the most common cause of invisible units.
Transparent objects rendering as opaque usually means blending got disabled somewhere, or the draw order is wrong so transparent stuff draws before what's behind it. Make sure the queue sorts transparent objects to the back and that the BlendScope RAII wrapper is being used.
When more than 15 units are visible on screen, the BattleRenderOptimizer kicks in to keep rendering fresh without sacrificing visual quality. This system provides several tricks that work independently of LOD:
Static or idle units are rendered on alternating frames. If a unit isn't moving, selected, or hovered, it may be skipped on odd or even frames based on its entity ID. This effectively cuts the render load for idle units in half while remaining imperceptible to the player.
Frame 1: Render units with (entity_id + frame) % 2 == 0
Frame 2: Render units with (entity_id + frame) % 2 == 0 (different set)
Moving units, selected units, and hovered units always render every frame to maintain responsiveness.
When the visible unit count exceeds 30 and units are far from the camera (>40 units away), animation updates are throttled. Instead of computing new poses every frame, distant units update their animations every 2-3 frames. This saves significant CPU time during large battles while keeping close-up units fully animated.
The batching ratio is boosted proportionally when more units are visible. This pushes more units into the primitive batching path, reducing draw call overhead during intense battles.
The optimizer can be configured via BattleRenderConfig:
- temporal_culling_threshold: Unit count that triggers temporal culling (default: 15)
- animation_throttle_threshold: Unit count that triggers animation throttling (default: 30)
- animation_throttle_distance: Distance beyond which animations are throttled (default: 40.0)
- animation_skip_frames: How many frames to skip for distant animations (default: 2)
See battle_render_optimizer.h for the implementation.
Let's trace a frame from start to finish. Qt's render thread calls our GLRenderer::render method in gl_view.cpp. That calls GameEngine::render, which calls SceneRenderer::begin_frame to clear the draw queue and reset frame state.
Game systems iterate through all entities. For each entity that needs rendering, they look up the appropriate renderer in the EntityRendererRegistry and call it. The renderer submits commands to the draw queue: mesh commands for body parts, cylinder commands for spear shafts, whatever's needed.
After all entities are processed, SceneRenderer::end_frame sorts the queue by the criteria we discussed (opacity, shader, texture, mesh), swaps the double buffer so the GPU gets the fresh queue, and calls Backend::execute with the freshly sorted commands.
Backend walks through commands in order. When it sees a run of similar commands, it batches them and hands them to the appropriate pipeline. CylinderPipeline gets all the cylinders and draws them instanced. TerrainPipeline handles ground chunks. Each pipeline binds its shader, sets uniforms, uploads any instance data, and issues draw calls.
The shaders run on the GPU, pulling in procedural detailsβrust patterns on armor, weave on fabric, scratches on shields. Simple Lambertian lighting gives everything shape. The fragment shader writes final colors to the framebuffer.
OpenGL rasterizes everything. Qt presents the framebuffer to the screen. And then we do it all again, 60 times a second.
The whole architecture optimizes for minimal state changes, parallel CPU/GPU work, and memory efficiency through procedural generation. There's still room for improvementβwe don't do frustum culling yet, and occlusion culling would help in complex scenesβbut the foundation is solid.
Here's a quick reference for common tasks:
| What you want to do | Where to look |
| Add a new unit type | render/entity/registry.cpp for registration, create new renderer in render/entity/nations |
| Change a nation's look | render/entity/nations/carthage or roman folders |
| Modify shaders | assets/shaders folder |
| Debug GL errors | render/gl/mesh.cpp has error checking after draws |
| Change draw order | render/draw_queue.h for command definitions, sort logic in draw_queue.cpp |
| Add a new effect | Create new Cmd struct in draw_queue.h, add pipeline in render/gl/backend |
| Debug the frame | Use RenderDoc to capture and step through |
| Tune battle performance | render/battle_render_optimizer.h for temporal culling and animation throttling |
The most common mistakes are calling OpenGL from the wrong thread (Qt's render thread is the only safe place), forgetting to bind the VAO before drawing (nothing appears), uploading instance data but calling the non-instanced draw function (only one object appears), or getting matrix conventions mixed up (everything is inside-out or flipped).
The RAII state scopes in state_scopes.h help prevent state leakage bugsβuse them whenever you need to temporarily change GL state. The uniform cache in Shader prevents per-frame overhead from name lookups.
When in doubt, fire up RenderDoc and trace a frame. You'll see exactly what gets bound, what gets drawn, and where time goes. Most rendering bugs become obvious once you can see the actual GPU work.