Rendering in Retrospect · #1 of 12
Building My First Renderer: What I Thought Rendering Was, and What It Actually Is
Retrospective — written in 2026, looking back.
The Renderer I Imagined
When I started studying graphics at DigiPen, I thought a renderer was a collection of effects. Draw triangles, add lighting, shadows, reflections — keep adding, and the image eventually looks real.
My first student renderer followed that logic. It was a single forward pass through a low-level graphics API, written in my systems language of choice at the time. Every object took roughly the same path: upload geometry, bind a shader, set uniforms for lights and materials, draw. A new kind of light meant a new branch. A new material behavior meant more code in the same shader. The structure was hard-coded because hard-coding felt like clarity: I could read the program top to bottom and point to where everything happened.
That clarity was an illusion, but a useful one. A simple forward renderer teaches the minimum pipeline end to end — how a vertex becomes a fragment, how depth decides what is visible. Everyone should build that version once. The mistake is believing it scales.
Rebuilding a similar scene now, in 2026, in a small personal custom engine, the code looks almost nothing like the first version. Not because the effects are fancier — the image is simpler. The difference is where decisions live: the first renderer made them in shader code and draw-call order; the current one makes them in data, passes, and budgets, before a single triangle is submitted. Three mistakes in that first build explain most of the difference.
Mistake One: Treating the GPU Like a Fast CPU
On a CPU we think of instructions executed one object at a time, with cheap branching and cheap access to any memory we want. My first renderer carried that mindset straight to the GPU. I looped over lights on the CPU, packed them into uniforms one draw at a time, changed state constantly between draws, and wrote shaders that branched heavily on material flags. The shader did per-fragment work to decide what kind of thing it was shading — work that should have been decided once, long before rasterization.
A GPU is a throughput machine: the same operation over a lot of data in parallel, with regular memory access patterns that let the hardware hide latency. Branch divergence, tiny draw calls with unique state, and scattered buffer access all fight that design. Light handling shows it concretely:
// Illustrative pseudocode, not a specific API
for (auto& mesh : scene.meshes) {
bindVertexBuffer(mesh);
bindMaterial(mesh.material);
// Re-upload every light, every draw, even if nothing changed
for (int i = 0; i < scene.lights.size(); ++i) {
setUniform("lightPos", i, scene.lights[i].pos);
setUniform("lightColor", i, scene.lights[i].color);
}
setUniform("model", mesh.transform);
draw(mesh);
}
Nothing here is wrong for five meshes and two lights. Everything here is wrong as a habit. The CPU does redundant work, the GPU receives the same light data dozens of times, and the shader loops over the maximum light count for every fragment, including fragments lit by none of them.
The version I write now asks the opposite question first: what data does a whole pass need, laid out once? Lights go into a single structured buffer for the pass. Materials are grouped so draws sharing state are submitted together. The shader still loops, but over stable, compact data uploaded once per frame, not once per object. Data flow first, code second — a principle I had no words for as a student.
Mistake Two: Adding Features Before Measuring
My student renderer gained features in the order I found tutorials for them, not in the order the image needed them. Normal mapping arrived before I had a reliable tone mapping step. A specular highlight I could barely see got more attention than a gamma error washing out everything.
I was not measuring: no captures, no GPU timestamps, no draw counts. I judged by screenshots, and any feature that made one slightly more impressive stayed, regardless of cost.
The discipline I try to keep in my personal engine now is boring on purpose — measure before adding. Before a new pass goes in, I ask: what visual problem does this solve, in one sentence? What does the current frame spend on the closest existing pass? And what am I willing to give up if this costs more than expected?
In the rebuild of that early scene — a few objects on a ground plane, one key light, a simple sky — the biggest improvements came not from new features but from fixing the order of operations I already had: rendering opaque objects front-to-back so early depth rejection could work, and stopping transparent objects from writing depth. They also came from questioning imported structure. For a small scene, a depth pre-pass can be a net loss: you pay a second submission of all geometry to save fragment work you may not have much of. That is not a rule against pre-passes. It is the general point — check for this scene, with this content, instead of importing a structure because larger engines use it. A renderer is not improved by accumulating passes. It is improved by spending a fixed budget better.
Mistake Three: Treating Lighting as Formulas, Not Data
As a student, lighting lived entirely inside the shader. The formulas were the lighting system. A different falloff meant editing code. A rougher object meant editing code. Art direction, to the extent it existed, was recompiling.
What I missed is that a lighting formula is only an interpreter. The controllable part is the data it interprets: light positions, colors, intensities, material parameters, and textures that vary those parameters across a surface. When that data is hard-coded or scattered across uniform calls, you cannot iterate, build tools, or reason about consistency between objects.
In my current personal renderer, a light is a small struct in a buffer and a material is a small parameter block — more on that later in this series. The shader is comparatively stable. Changing a scene's mood means changing data, often from a file I can reload without recompiling, not changing the interpreter.
The separation also localizes bugs. If every object looks wrong the same way, the interpreter is suspect. If one object looks wrong, its data is suspect. In the hard-coded version those cases were indistinguishable, and I spent many student hours recompiling to test a color change that should have been a data edit.
Rebuilding the Same Scene in 2026
I keep that first test scene around: a ground plane, a handful of primitives and a couple of loaded meshes, one directional light and a few point lights, a basic sky. It is not impressive, and that is the point — a controlled scene where I know what should happen, so regressions are obvious.
The 2026 version is a short list of passes instead of one monolithic loop: a shadow pass, an opaque pass writing depth and shaded color, a sky pass, and a final tone mapping pass. Transparent objects, when added for a test, go after opaque, sorted back-to-front, without depth writes.
None of that is novel. What differs is intent: each pass has a defined input and output, and I can disable, capture, or inspect any one in isolation. The renderer became a small dataflow graph I execute in order. I also keep the scene deliberately under-budget — if an experiment cannot justify its cost here, it does not earn a place in the engine.
What I Think a Renderer Is Now
I no longer think of a renderer as an effects checklist. I think of it as a budget system that happens to produce images. Every frame has finite GPU time, bandwidth, and memory. Every feature is a competing claim on those budgets: shadows compete with resolution, material complexity with light count, post-processing with everything before it. The job is not to maximize any one of them. It is to allocate.
That framing explains the three principles I now write at the top of any new experiment:
- 1. Measure before adding. A capture or a timestamp beats an opinion about performance, including my own.
- 2. Data flow first. Define what data a pass needs, how it is laid out, and how often it changes, before writing the shader that consumes it.
- 3. A renderer is a budget system. If a feature has no stated cost and no stated trade-off, it is not designed yet — it is just added.
My first renderer taught me how to make an image appear on screen — a real skill. It taught me almost nothing about making the next image cheaper, clearer, or easier to change. Those lessons came from rebuilding the same scene years later and noticing the hard problems were never the formulas: they were where the data lived, how it moved, and what I chose to spend — the thread for this series.