Renderer Frontend/Backend Split
Tracking: #89 (implement), #93 (research). Packed shader contracts: model-shader.md. Platforms: build-and-renderer-platforms.md.
Problem
GL state changes (glEnable, glDisable, glBlendFunc, glDepthMask, glDepthFunc, glColorMask, glCullFace, glPolygonOffset, glBlendEquation, etc.) are scattered across every draw function in the renderer. Every 2D draw, every terrain pass, every model render, and every fog-of-war composite sets its own GL state inline via R_Call(gl...) before each draw call.
Current call inventory
| State | Unique call sites |
|---|---|
glEnable(GL_CULL_FACE) |
6 (engine) + 4 (game) |
glDisable(GL_CULL_FACE) |
8 (engine) + 5 (game) |
glEnable(GL_DEPTH_TEST) |
3 (engine) + 5 (game) |
glDisable(GL_DEPTH_TEST) |
4 (engine) |
glDepthMask |
3 (engine) + 10 (game) |
glDepthFunc |
3 (engine) + 3 (game) |
glBlendFunc |
10 (engine) + 12 (game) |
glColorMask |
3 (engine) + 1 (game) |
glEnable(GL_BLEND) |
6 (engine) + 8 (game) |
glPolygonOffset |
0 (engine) + 4 (game) |
glBlendEquation |
2 (engine) |
glEnable(GL_SCISSOR_TEST) |
2 (engine) |
| Total | ~85 scattered GL state calls |
Consequences:
- Same state is set repeatedly across unrelated draw functions (e.g. glDisable(GL_CULL_FACE) + glEnable(GL_BLEND) + glBlendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA) appears in R_DrawChar, R_DrawFill, R_DrawImageBatch, R_DrawWireRect, R_DrawBoundingBox, R_DrawMinimapCameraRect).
- No way to know what state a function expects vs. what state it leaves behind.
- State changes between consecutive identical surfaces are never skipped.
- Game-specific renderers (WC3, SC2, WoW) duplicate the same boilerplate.
View ownership
R_RenderFrame() (renderer/r_view.c) leaves the latest world view in tr.viewDef for HUD projection consumers.
Scenes carrying RDF_NOWORLDMODEL borrow that state only for their render and restore the complete prior view on
both the entity-camera and general rendering paths. Resetting GL viewport/scissor state is a separate operation.
This preserves the gameplay projection, frustum, viewport, flags, and lighting across portrait draws. The regression
and runtime evidence are documented in WC3 minimap camera outlines.
Alpha-key coverage contract
The shared renderer requests the compile-time MSAA=0/2/4/8 sample count before creating the SDL
window; the bandwidth-safe default is zero. After context creation it logs both SDL's returned attributes and OpenGL's
GL_SAMPLE_BUFFERS/GL_SAMPLES; the latter determine tr.msaa_samples. Context creation
is retried without MSAA only when the requested multisample visual is unavailable, and
that downgrade is logged.
MDX, M2, M3, alpha-key particles, and WoW grass share one material contract:
- an
MSAA=0fragment shader discards below the authoritative cutoff; an MSAA build remaps the edge withsmoothstep/fwidth; - with a multisampled target,
R_SetAlphaKeyState(true)disables blending, enablesGL_SAMPLE_ALPHA_TO_COVERAGE, and retains depth writes; - if an MSAA build cannot obtain a multisampled context, it logs the downgrade and uses
ordinary alpha blending with depth writes disabled; an
MSAA=0build uses hard discard and depth writes; - every non-alpha-key material path and frame start disables alpha-to-coverage so the state cannot leak into true blended, additive, UI, or terrain passes.
Alpha-to-coverage is for cutout coverage, not general order-independent transparency. True blended and additive material modes keep their existing blend/depth contracts. See Build And Renderer Platforms for build commands and the GLES3 contract.
Renderer profiling cvars
Use r_stats 1 to print one averaged line per second:
[R_STATS] fps=... draws=... vertices=... triangles=... instances=...
[WOW_STATS] terrain=drawn/considered ... wmo=instances/models groups=... draws=... textures=... model_batched=... doodads=visible/candidates ...
R_STATS counts every renderer draw submission, including UI, minimap, fog, particles,
models, terrain, and instanced amplification. instances is the sum of each draw's
instance count; non-instanced draws contribute one. Use the WoW pass toggles to isolate
view-dependent costs without changing asset loading or simulation:
| Cvar | Default | Draws |
|---|---|---|
r_grass |
1 | GroundEffectDoodad instanced batches |
r_doodads |
1 | ADT doodad M2s and map-object debug geometry |
r_wmos |
1 | WMO groups in the world |
r_terrain |
1 | ADT terrain in the world |
r_minimap |
1 | Blizzard minimap tiles (normally 1-4 draws) |
r_entities |
1 | Snapshot entities |
r_particles |
1 | Particle batches |
r_fogofwar |
1 | Fog-of-war passes |
r_fog |
1 | WoW distance fog (turning it off exposes the hard clip) |
r_fog_start |
500 | WoW outdoor fog start in world units |
r_fog_end |
650 | WoW fully opaque fog / WMO CPU-cull distance |
r_swapinterval |
1 | SDL/OpenGL presentation interval (0 uncapped request, 1 display synchronized) |
For the Human start and left-facing slowdown, launch with +set r_stats 1, turn left, then
toggle one pass at a time in the console, for example set r_wmos 0, set r_doodads 0,
set r_grass 0, and set r_minimap 0. Compare both FPS and draw counts; restore each
toggle before testing the next so effects do not overlap.
Renderer cvars are registered during common initialization, so the shorter Quake-style
console form is also valid: r_grass 0, r_wmos 0, and r_stats 1. set remains useful
for creating an ad-hoc cvar; renderer controls must not rely on that side effect.
WoW Human-start checkpoint (2026-08-18)
At 2048x1536 with 4x MSAA, the forward-facing Human start submits about 1,744 draws, 6.84 million vertices, 2.28 million triangles, and 467,000 instances per frame. Its world pass contains 133 visible terrain chunks, 505 WMO batch draws, and 276 visible doodads. Isolating one pass at a time found:
| Disabled pass | Draws/frame | FPS | Approximate draw reduction |
|---|---|---|---|
| none | 1,744 | 94-95 | - |
| live minimap | 1,378 | 94 | 367 |
| WMO | 992 | 103 | 750 |
| doodads | 1,430 | 104 | 315 |
| grass | 1,731 | 92 | negligible |
The FPS values are directional rather than additive because macOS Metal/OpenGL driver work varies between runs. The draw deltas are the useful isolation signal. Grass is 14 persistent instanced batches and is not the draw-call bottleneck.
The original WMO pass reused the four-layer terrain shader by binding every WMO texture
to units 0-3 and white to unit 4 for every material. This matched profiler time in
glActiveTexture, glBindTexture, sampler loading, and Metal pipeline preparation.
The WMO single-texture shader branch now samples unit 0 only; the same scene improved
from roughly 82-90 FPS to 94-95 FPS without changing draw count. The remaining scalable
cost was the number of separately submitted WMO and doodad batches, plus the duplicate
live-minimap world pass.
After static-doodad instancing, authoritative minimap tiles, and hybrid WMO material
batching, the same forward Human-start view measured about 910 total draws at the 120
FPS presentation ceiling. Its WMO work fell from 486 to 207 draws: 10 of 17 visible
instances used model-wide material batches, while sparse instances retained per-group
culling. The minimap fell from roughly 367 duplicated world draws to at most four UI
quads. r_swapinterval 0 is useful for requesting uncapped presentation, but macOS's
OpenGL-on-Metal path may still present at the display's 120 Hz ceiling.
The FPS overlay uses one batched system-font submission and displays
FPS ## Drawcalls ##; its draw count is captured before the overlay itself.
Target API shape
Do not emulate OpenGL 1.x (glEnable(GL_LIGHTING), fog modes, texture-env, uber-shader permutations).
Call-site glUniform* is already gone (shader_desc_t + typed state + R_ApplyShader). The remaining work
follows Sebastian Aaltonen's thin-pipeline / root-struct design, mapped onto the current OpenGL 3.1 / GLES3
backend. Issues: #89,
#93. Source:
SebAaltonen, 2026-09-03.
| Steal | Meaning here |
|---|---|
| Root struct + one push | One alignas(16) C struct shared with the shader: transforms, material, texture indices. Fill a stack/bump copy and upload once (UBO / persistent-mapped buffer today; push constants later). |
| Thin pipeline objects | Graphics pipeline = shaders + color/depth formats + a small raster blob (blend, depth write/test, cull, write mask). One short create call. |
| Blend/depth as small objects | Attached at pipeline create. On GL they can also change cheaply; the backend diffs them with a Doom 3-style bitmask. Not a giant opaque PSO. |
| Texture/sampler heap | 32-bit indices in the root struct. No per-draw glBindTexture storm. GLES without bindless needs an explicit, logged strategy. |
| 64-bit GPU pointers | Not required on GL/GLES. Do not block #89 on vkCmdPushDataEXT or buffer device address. |
Lighting, fog, and grass stay packed data in the root struct (MODELLIGHTING, uGrassParams, model fog flags).
See model-shader.md. They are not public lightingEnabled / fogMode bits that pick shader permutations.
Typical draw shape: bind pipeline if changed, push root once, draw.
Backend state cache (Phase 1 of #89)
Doom 3's R_* / RB_* split and GL_State(uint64) XOR remain the GL implementation of cheap blend/depth/cull
packets. Frontend never issues glEnable / glBlendFunc / glDepthMask. Backend owns those calls.
backEndState_t:
uint32 glStateBits; // packed blend/depth/color-mask
GLenum faceCulling;
GLenum depthFunc;
DWORD polygonOffsetScale, polygonOffsetBias;
DWORD blendEquation;
DWORD activeTextureUnit;
DWORD currentPipeline;
DWORD currentVAO;
DWORD currentFBO;
RECT currentScissor;
void RB_State(uint32 bits);
void RB_Cull(GLenum mode);
void RB_PolygonOffset(float scale, float bias);
void RB_BlendEquation(GLenum eq);
void RB_Scissor(LPCRECT r);
void RB_BindVAO(DWORD vao);
void RB_BindFBO(DWORD fbo);
void RB_SetViewport(LPCRECT r);
Each helper compares against the cached value and only issues the GL call on delta. Later phases in #89 add thin pipeline objects, the root-struct push, and the texture heap. Surface sorting (by pipeline + blend/depth bits) waits until draws are no longer immediate.
Key constraints
R_Call(gl...)stays for diagnostics;RB_*uses it internally.refExport_tis the client/renderer ABI. After changing its layout, runmake buildbefore launching;make testmay rebuild a renderer dylib without relinking an existing game executable, and mixing those artifacts dispatches calls through the wrong function-table slots.- Pipeline/root types live in the renderer (
r_backend.hviar_local.h). - Callers never reference
progidor uniform locations.shader_desc_tremains the GLSL grammar until the root-struct push replaces per-fieldglUniform*. - One representation per shader concept: no zero-count modes or parallel fallback uniforms.
- macOS GL 4.1, desktop GL 3.1, and GLES3 Mali-G31 each need an explicit transport. Missing extensions are logged; do not silently demote to a bind loop and call it bindless.
Files for the state-cache step
| File | Action |
|---|---|
renderer/r_backend.h |
New — backEndState_t, pipeline/root types, RB_* |
renderer/r_backend.c |
New — cache, later pipeline bind + root push |
renderer/r_local.h |
#include "r_backend.h" |
renderer/r_main.c, r_draw.c, r_fogofwar.c, r_particles.c, r_ents.c |
RB_* instead of raw GL state |
| WC3 / WoW / SC2 game renderers | Same; listed in #89 |
Verification
make clean && make— no warnings.- Visual: WC3 ROC and TFT, SC2, WoW; load a map.
+screenshot N +com_frame_limit. rg 'gl(Enable|Disable|BlendFunc|DepthFunc|DepthMask|ColorMask|CullFace|PolygonOffset|BlendEquation)\b' renderer/ games/*/renderer/— only insider_backend.c.r_stats 1on a known scene so draw count does not regress silently.