How 3D Graphics Work - Drawing Your First Triangle
Introduction
The first time I drew a triangle on screen with graphics programming, I felt like a wizard. I had copied some OpenGL code from a tutorial, hit compile, and there it was: a perfect red triangle. But I had absolutely no idea what I had just done.
I was calling functions like glBindBuffer and glVertexAttribPointer, writing these small programs called "shaders" that somehow ran on the GPU, and somehow it all worked. But I didn't understand why. I didn't understand what was actually happening between my code and those pixels on screen.
The second time I drew a triangle from scratch - no tutorial, just from memory - I started to really get it. By the third time, I finally understood the entire journey from vertices in my code to colored pixels on screen.
This post is that journey. We're going to draw a single triangle, but we're going to understand every single step of what happens. No hand-waving. We'll go from CPU to GPU, from 3D coordinates to 2D pixels, and we'll understand exactly what the graphics pipeline does. I'll use OpenGL for examples, but the concepts apply to any graphics API - DirectX, Vulkan, Metal, WebGL - they all work the same way fundamentally.
Let's draw a triangle.
What We're Building: The 30,000 Foot View
Before we write a single line of code, let's understand what we're trying to do. When you call a "draw" function in graphics programming, here's what happens:
The Journey From Your Code to Pixels on Screen:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Your Code 2. Upload to 3. GPU Processes 4. Display
(CPU) GPU Memory the Data on Screen
│ │ │ │
▼ ▼ ▼ ▼
┌──────┐ ┌────────┐ ┌──────────┐ ┌──────────┐
│Three │ │ GPU │ │ Vertex │ │ │
│vertex│ =======> │ Memory │ =====> │ Shader │ ====> │ ▲ │
│coords│ Copy │ (VRAM) │ Read │ │ Draw │ / \ │
└──────┘ └────────┘ │ Fragment │ │ ──── │
│ Shader │ │Triangle! │
└──────────┘ └──────────┘
The graphics pipeline is like an assembly line in a factory:
- Raw materials (vertex coordinates) go in
- Multiple processing stages transform the data
- Final product (colored pixels) comes out
The beautiful thing? Most of this pipeline is programmable. You write small programs called "shaders" that run on the GPU and control exactly how things look.
Let's build this from scratch, step by step.
Step 1: Define Your Triangle Vertices
A triangle has three corners. In graphics programming, we call each corner a vertex (plural: vertices). In 3D space, each vertex needs three coordinates to specify its position: X, Y, and Z.
// Three vertices that define our triangle
float vertices[] = {
// X Y Z
-0.5f, -0.5f, 0.0f, // Bottom-left vertex
0.5f, -0.5f, 0.0f, // Bottom-right vertex
0.0f, 0.5f, 0.0f // Top vertex
};Right now, these are just nine floating-point numbers sitting in your computer's RAM. The GPU doesn't know they exist yet. They're just data.
Let's visualize what these coordinates mean in 3D space:
Our Triangle in 3D Space:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Y
▲
│
│ (0.0, 0.5, 0.0)
│ *
│ / \
│ / \
│ / \
│ / \
│ / \
│ *-----------*
│ (-0.5,-0.5) (0.5,-0.5)
│
└──────────────────────────► X
/
/
▼ Z (pointing into the screen)
A few important things to understand about this coordinate system:
- X goes left to right: negative X is left, positive X is right
- Y goes bottom to top: negative Y is down, positive Y is up
- Z goes in and out: negative Z is into the screen, positive Z is toward you
- All our Z values are 0: this means our triangle is completely flat against the XY plane
We're using what's called a right-handed coordinate system. For now, keeping Z at 0 makes everything simpler to visualize.
Step 2: Upload Data to GPU Memory
Here's something crucial to understand: the GPU has its own memory, completely separate from your computer's regular RAM. This GPU memory is called VRAM (Video RAM). It's much, much faster for the GPU to access than regular RAM.
We need to copy our vertex data from CPU RAM to GPU VRAM. OpenGL provides a way to do this:
// 1. Create a buffer object on the GPU
GLuint VBO; // VBO = Vertex Buffer Object (it's just an ID number)
glGenBuffers(1, &VBO);
// 2. Bind it (tell OpenGL "this is the buffer I want to talk about")
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 3. Upload our data from CPU RAM to GPU VRAM
glBufferData(GL_ARRAY_BUFFER, // Target: array buffer
sizeof(vertices), // Size: 36 bytes (9 floats × 4 bytes)
vertices, // Source: our array in CPU RAM
GL_STATIC_DRAW); // Hint: this data won't change oftenLet me explain what each function does:
glGenBuffers(1, &VBO)
- "Hey OpenGL, ask the GPU to allocate a new buffer and give me an ID for it"
- After this call,
VBOcontains a number like1or42- it's just an identifier - Think of it like getting a locker number at a gym - you don't own the locker, you just have a number that identifies it
glBindBuffer(GL_ARRAY_BUFFER, VBO)
- "I want to talk about buffer #42 now"
- OpenGL is what we call a state machine - you "bind" things to make them the "current" or "active" thing
- All commands that affect array buffers will now affect this specific buffer until you bind a different one
glBufferData(...)
- "Copy these bytes from CPU RAM to GPU VRAM"
sizeof(vertices)= 9 floats × 4 bytes per float = 36 bytes totalGL_STATIC_DRAWis a hint that says "this data probably won't change much" (helps GPU optimize)
Here's what just happened in memory:
Memory Layout Before Upload: Memory Layout After Upload:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CPU RAM: CPU RAM:
┌────────────────────────┐ ┌────────────────────────┐
│ vertices[] array │ │ vertices[] array │
│ -0.5, -0.5, 0.0, │ │ -0.5, -0.5, 0.0, │
│ 0.5, -0.5, 0.0, │ │ 0.5, -0.5, 0.0, │
│ 0.0, 0.5, 0.0 │ │ 0.0, 0.5, 0.0 │
└────────────────────────┘ └────────────────────────┘
Very slow for GPU │
to access directly │ glBufferData()
│ copied the data
▼
GPU VRAM (Buffer #42):
┌────────────────────────┐
│ -0.5, -0.5, 0.0, │
│ 0.5, -0.5, 0.0, │
│ 0.0, 0.5, 0.0 │
└────────────────────────┘
SUPER FAST for GPU!
Now the GPU has direct access to our vertex data and can process it at lightning speed.
Step 3: Describe the Data Layout
The GPU now has 36 bytes of data in its memory, but it doesn't know what those bytes mean. Is it three vertices with three components each? Nine separate values? Maybe colors? Texture coordinates? The GPU has no idea.
We need to describe the structure of the data - we call this defining a vertex attribute:
// Enable vertex attribute at index 0
glEnableVertexAttribArray(0);
// Describe how to interpret the data in the buffer
glVertexAttribPointer(
0, // Attribute index (we'll use 0)
3, // Number of components per vertex (X, Y, Z = 3)
GL_FLOAT, // Data type of each component
GL_FALSE, // Don't normalize the values
3 * sizeof(float), // Stride: bytes to skip to get to next vertex
(void*)0 // Offset: where to start reading in the buffer
);This is probably the most confusing function in OpenGL, so let me break down each parameter:
Parameter 1: 0 (attribute index)
- "This describes attribute number 0"
- We can have multiple attributes (position, color, texture coords, etc.)
- We'll reference this index in our vertex shader later
Parameter 2: 3 (size)
- "Each vertex has 3 components"
- For positions, that's X, Y, Z
- For colors, that would be R, G, B
- For texture coordinates, that would be U, V (only 2)
Parameter 3: GL_FLOAT (type)
- "Each component is a 32-bit floating-point number"
- Could also be GL_INT, GL_UNSIGNED_BYTE, etc.
Parameter 4: GL_FALSE (normalized)
- "Don't normalize the values"
- If TRUE, integers would be converted to the 0.0-1.0 range
- We're already using floats, so we don't need this
Parameter 5: 3 * sizeof(float) (stride)
- "To get from one vertex to the next, skip forward 12 bytes"
- This is 3 floats × 4 bytes = 12 bytes
- If we had interleaved data (position, color, position, color...), stride would be larger
Parameter 6: (void*)0 (offset)
- "Start reading at byte 0 of the buffer"
- If position data started at byte 12, we'd use
(void*)12
Here's a visual of how the GPU interprets our buffer with this description:
Buffer Memory Layout (How the GPU Reads It):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Byte offset: 0 4 8 12 16 20 24 28 32
│ │ │ │ │ │ │ │ │
Buffer data: [-0.5|-0.5| 0.0| 0.5|-0.5| 0.0| 0.0| 0.5| 0.0]
└────┴────┴────┘ │ │
Vertex 0 │ │
X Y Z │ │
│ │
Stride = 12 bytes │ │
──────────────────►│ │
│ │
└────┴────┴────┘
Vertex 1
X Y Z
Stride = 12 bytes
─────────────────────────►
│
└────┴────┴────┘
Vertex 2
X Y Z
Now the GPU understands: "Read 3 floats, that's vertex 0. Jump forward 12 bytes, read 3 floats, that's vertex 1. Jump forward 12 more bytes, read 3 floats, that's vertex 2."
Step 4: Write a Vertex Shader
Here's where things get really interesting. A vertex shader is a small program that runs on the GPU for every single vertex. It's written in a language called GLSL (OpenGL Shading Language), which looks a lot like C.
The vertex shader's job is to take a vertex position and output where that vertex should appear on screen.
// Vertex Shader (this is GLSL code, not C++)
#version 330 core
// Input: vertex position from our buffer
// "location = 0" matches the glVertexAttribPointer(0, ...) we set up
layout(location = 0) in vec3 vertexPosition;
// Main function - this runs once for EACH vertex
void main() {
// Output: final position in "clip space"
// gl_Position is a special built-in variable
gl_Position = vec4(vertexPosition, 1.0);
}Let me explain each part of this shader:
#version 330 core
- Specifies which version of GLSL we're using (3.30, which corresponds to OpenGL 3.3)
coremeans we want the modern profile without deprecated features
layout(location = 0) in vec3 vertexPosition
layout(location = 0)matches the attribute index 0 we used in glVertexAttribPointerinmeans this is an input variable (data flows into the shader)vec3is a 3-component vector type (X, Y, Z)vertexPositionis just a variable name we chose (could be anything)
void main()
- Entry point of the shader, just like main() in C
- Runs once per vertex
- No parameters, no return value
gl_Position = vec4(vertexPosition, 1.0)
gl_Positionis a special built-in output variable- It MUST be a
vec4(4-component vector: X, Y, Z, W) - We're converting our vec3 (three components) to vec4 by adding a W component of 1.0
- The W component is used for perspective projection, but with W=1.0 we're saying "no projection"
Wait, what's this W component?
Great question! In computer graphics, we use something called "homogeneous coordinates" which have four components instead of three. The W component is used to create perspective (things far away look smaller). For our simple flat triangle, we set W=1.0, which means "no perspective effects, just use the X, Y, Z coordinates as they are."
How the Vertex Shader Actually Executes
The vertex shader doesn't run once - it runs in parallel for ALL vertices. If you have 10,000 vertices, the GPU might literally run your shader on 1,000 of them simultaneously.
Vertex Shader Execution (Massively Parallel):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Input from Buffer: GPU Cores (parallel): Output:
Vertex 0: ┌──────────────────┐ gl_Position:
(-0.5, -0.5, 0.0) ─────► │ Core 1 │─────► (-0.5, -0.5, 0.0, 1.0)
│ Runs main() │
└──────────────────┘
Vertex 1: ┌──────────────────┐ gl_Position:
(0.5, -0.5, 0.0) ─────► │ Core 2 │─────► (0.5, -0.5, 0.0, 1.0)
│ Runs main() │
└──────────────────┘
Vertex 2: ┌──────────────────┐ gl_Position:
(0.0, 0.5, 0.0) ─────► │ Core 3 │─────► (0.0, 0.5, 0.0, 1.0)
│ Runs main() │
└──────────────────┘
All three run at the EXACT SAME TIME!
This is why GPUs are so incredibly fast at graphics!
Modern GPUs have thousands of these cores. This massive parallelism is their superpower. This is why a GPU can process millions of vertices per frame at 60+ frames per second.
Compiling the Shader
The shader code needs to be compiled on the GPU before we can use it:
// Create a vertex shader object
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
// Attach the source code string to the shader object
const char* vertexShaderSource = /* the GLSL code from above */;
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
// Compile the shader
glCompileShader(vertexShader);
// Always check for compilation errors!
GLint success;
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success) {
char infoLog[512];
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
printf("ERROR: Vertex shader compilation failed:\n%s\n", infoLog);
}We'll need to link this with a fragment shader (which we'll write next) to create a complete shader program. But first, let's understand what happens between the vertex shader and fragment shader.
Step 5: Rasterization - From Triangles to Pixels
After the vertex shader runs for all three vertices, we have three positions in what's called "clip space." Now the GPU needs to figure out: which pixels on the screen are inside this triangle?
This process is called rasterization, and it's a fixed function in the GPU - meaning you don't program it yourself, it just happens automatically. But understanding it is absolutely crucial.
What Exactly Is Rasterization?
Rasterization is the process of converting geometric shapes (like our triangle) into discrete pixels (a raster image).
Rasterization: From Triangle to Pixels
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Input: Three Vertex Positions Output: Covered Pixels
V2 ·······
* ····█····
/|\ ···███···
/ | \ ··█████··
/ | \ ·███████·
/ | \ █████████
/ | \ ███████████
/ | \ █████████████
/ | \
*───────+───────* Each █ is a pixel
V1 V3 that needs to be colored!
The rasterizer goes through the triangle systematically and determines which pixels are inside it. For each pixel that's inside, it creates what's called a fragment.
Think of a fragment as a "pixel candidate" - it might become a final pixel on screen, or it might be discarded later (for example, if something else is in front of it).
The Magic of Interpolation
Here's something really cool: the rasterizer doesn't just tell you which pixels are covered. It also interpolates (smoothly blends) values across the triangle.
Let's say we give each vertex a color:
- Vertex 0 (bottom-left): Red (1, 0, 0)
- Vertex 1 (bottom-right): Green (0, 1, 0)
- Vertex 2 (top): Blue (0, 0, 1)
The rasterizer will automatically calculate smooth color gradients between them:
Color Interpolation Across the Triangle:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Blue (0, 0, 1)
*
/█\ Pixels near the corners
/██\ get colors close to that
/███\ corner's color.
/████\
/█████\ Pixels in the middle get
/██████\ a smooth blend of all
/███████\ three colors!
/████████\
*──────────* The result is a beautiful
Red Green gradient that smoothly
(1,0,0) (0,1,0) transitions between colors.
This interpolation works for any data you pass from the vertex shader to the fragment shader - colors, texture coordinates, normals for lighting, custom data, anything! The GPU handles it automatically.
The GPU uses something called perspective-correct interpolation, which accounts for 3D depth so that things look right even when viewed from an angle. But for our flat triangle, this isn't really visible.
Step 6: Write a Fragment Shader
Now for the final piece of the puzzle! A fragment shader runs for every single fragment (every pixel candidate inside the triangle). This is where you decide what color each pixel should be.
// Fragment Shader (GLSL code)
#version 330 core
// Output: the final color of this pixel
out vec3 color;
// Main function - runs once per fragment
void main() {
// Make everything pure red!
color = vec3(1.0, 0.0, 0.0);
}This is about as simple as a fragment shader can get. Let me explain:
out vec3 color
outmeans this is an output variablevec3is RGB color (Red, Green, Blue)- Each component goes from 0.0 (none of that color) to 1.0 (full intensity)
- This variable gets written to the framebuffer (what you see on screen)
color = vec3(1.0, 0.0, 0.0)
- Red = 1.0 (full red)
- Green = 0.0 (no green)
- Blue = 0.0 (no blue)
- Result: pure red!
Every pixel inside the triangle will be colored pure red with this shader.
Making It More Interesting: Interpolated Colors
Let's make our triangle more colorful by using interpolated colors from each vertex. We'll need to update both shaders:
// Updated Vertex Shader
#version 330 core
layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec3 vertexColor; // NEW: color input per vertex
out vec3 fragmentColor; // NEW: pass color to fragment shader
void main() {
gl_Position = vec4(vertexPosition, 1.0);
fragmentColor = vertexColor; // Just pass it along
}// Updated Fragment Shader
#version 330 core
in vec3 fragmentColor; // NEW: receive interpolated color from vertex shader
out vec3 color;
void main() {
color = fragmentColor; // Use the interpolated color
}The magic here is that fragmentColor gets automatically interpolated by the rasterizer! Each fragment receives a smoothly blended color based on its position between the three vertices.
To make this work, we'd need to update our vertex data to include colors:
float vertices[] = {
// Positions // Colors
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // Bottom-left: Red
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // Bottom-right: Green
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // Top: Blue
};And set up the second attribute:
// Position attribute (location = 0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// Color attribute (location = 1)
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float),
(void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);Now stride is 6 floats (position + color), and the color attribute starts at offset 3.
Compiling the Fragment Shader
Just like the vertex shader, we need to compile it:
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
// Check for errors
GLint success;
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success) {
char infoLog[512];
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
printf("ERROR: Fragment shader compilation failed:\n%s\n", infoLog);
}Linking Shaders Into a Program
The vertex and fragment shaders need to be linked together into a shader program:
// Create a program object
GLuint shaderProgram = glCreateProgram();
// Attach both shaders
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
// Link them together
glLinkProgram(shaderProgram);
// Check for linking errors
GLint success;
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
char infoLog[512];
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
printf("ERROR: Shader program linking failed:\n%s\n", infoLog);
}
// Once linked, we can delete the individual shader objects
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);Step 7: Actually Drawing the Triangle
We've uploaded vertex data, written shaders, compiled and linked them. Now we can finally draw!
// Tell OpenGL to use our shader program
glUseProgram(shaderProgram);
// Bind the vertex buffer we want to draw from
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// Draw the triangle!
glDrawArrays(GL_TRIANGLES, // We're drawing triangles
0, // Start at vertex 0
3); // Use 3 vertices totalglUseProgram(shaderProgram)
- "Use these shaders for all rendering until I say otherwise"
- You can have multiple shader programs and switch between them
glBindBuffer(GL_ARRAY_BUFFER, VBO)
- "Use this vertex buffer for the draw call"
glDrawArrays(GL_TRIANGLES, 0, 3)
- "Draw triangles using vertices 0, 1, and 2"
- The GPU now executes the entire pipeline for these three vertices
- Magic happens, and pixels appear on screen!
And just like that, your triangle appears! All that setup, all those function calls, all those shaders - they all come together in this one moment.
The Complete Pipeline: Tracing the Journey
Let's trace what happens when you call glDrawArrays, following one vertex all the way through:
Complete Graphics Pipeline Execution:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. Application Stage (CPU)
│
├─ You call: glDrawArrays(GL_TRIANGLES, 0, 3)
├─ OpenGL prepares the draw call
└─ Sends command to GPU
│
▼
2. Vertex Shader Stage (GPU - Parallel)
│
├─ GPU reads vertices from VBO: (-0.5,-0.5,0), (0.5,-0.5,0), (0,0.5,0)
├─ Runs vertex shader main() for each vertex (in parallel!)
└─ Outputs 3 positions in clip space
│
▼
3. Primitive Assembly
│
├─ GPU takes the 3 vertices and forms a triangle primitive
└─ Prepares for rasterization
│
▼
4. Rasterization Stage (GPU - Fixed Function)
│
├─ Determines which pixels are inside the triangle
├─ Generates fragments for each covered pixel (maybe 1000+ fragments!)
├─ Interpolates vertex attributes (like colors) for each fragment
└─ Passes fragments to fragment shader
│
▼
5. Fragment Shader Stage (GPU - Parallel)
│
├─ Runs fragment shader main() for each fragment (in parallel!)
├─ Each fragment calculates its final color
└─ Outputs colors for all fragments
│
▼
6. Per-Fragment Operations
│
├─ Depth test (is this fragment in front of what's already there?)
├─ Stencil test (for masking effects)
├─ Blending (for transparency)
└─ Fragments that pass all tests continue
│
▼
7. Framebuffer Write
│
├─ Final colors are written to the framebuffer
└─ Framebuffer is displayed on your monitor
│
▼
TRIANGLE ON SCREEN!
The entire pipeline is designed to be massively parallel. While vertex shaders are running for some vertices, fragment shaders might already be running for fragments from previous triangles. It's like a multi-stage assembly line running at full speed.
Putting It All Together: Complete Working Code
Here's a complete, working program that brings everything together:
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <stdio.h>
#include <stdlib.h>
// Vertex shader source code
const char* vertexShaderSource = R"(
#version 330 core
layout(location = 0) in vec3 vertexPosition;
layout(location = 1) in vec3 vertexColor;
out vec3 fragmentColor;
void main() {
gl_Position = vec4(vertexPosition, 1.0);
fragmentColor = vertexColor;
}
)";
// Fragment shader source code
const char* fragmentShaderSource = R"(
#version 330 core
in vec3 fragmentColor;
out vec3 color;
void main() {
color = fragmentColor;
}
)";
int main() {
// Initialize GLFW
if (!glfwInit()) {
printf("Failed to initialize GLFW\n");
return -1;
}
// Create a window
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(800, 600, "My First Triangle!", NULL, NULL);
if (!window) {
printf("Failed to create window\n");
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
// Initialize GLEW
if (glewInit() != GLEW_OK) {
printf("Failed to initialize GLEW\n");
return -1;
}
// Compile vertex shader
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
// Check vertex shader compilation
GLint success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
printf("Vertex shader compilation failed:\n%s\n", infoLog);
}
// Compile fragment shader
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
// Check fragment shader compilation
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
printf("Fragment shader compilation failed:\n%s\n", infoLog);
}
// Link shaders into a program
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
// Check program linking
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
printf("Shader program linking failed:\n%s\n", infoLog);
}
// Clean up shader objects (we don't need them after linking)
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
// Vertex data: position and color for each vertex
float vertices[] = {
// Positions // Colors (RGB)
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // Bottom-left: Red
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // Bottom-right: Green
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // Top: Blue
};
// Create and configure the vertex buffer
GLuint VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// Create a Vertex Array Object (VAO) to store attribute configuration
GLuint VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
// Configure position attribute (location = 0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// Configure color attribute (location = 1)
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float),
(void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
// Rendering loop
while (!glfwWindowShouldClose(window)) {
// Clear the screen to black
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// Use our shader program
glUseProgram(shaderProgram);
// Bind our VAO (which has all the attribute configuration)
glBindVertexArray(VAO);
// Draw the triangle!
glDrawArrays(GL_TRIANGLES, 0, 3);
// Swap buffers and poll events
glfwSwapBuffers(window);
glfwPollEvents();
}
// Cleanup
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteProgram(shaderProgram);
glfwTerminate();
return 0;
}To compile this on Linux:
g++ -o triangle triangle.cpp -lGL -lGLEW -lglfw
./triangleOn macOS:
g++ -o triangle triangle.cpp -framework OpenGL -lGLEW -lglfw
./triangleRun it, and you'll see a beautiful triangle with red, green, and blue corners and smooth color gradients in between!
Why This Matters: Understanding the Foundation
You now understand the complete journey from code to pixels. Let's recap what we've learned:
- Vertices define 3D geometry (in our case, three points of a triangle)
- VBO stores vertex data in GPU memory for fast access
- Vertex Attributes describe how to interpret the raw data
- Vertex Shader processes each vertex (runs on GPU in parallel)
- Rasterization determines which pixels are inside the triangle
- Interpolation smoothly blends values across the triangle
- Fragment Shader determines the color of each pixel (runs on GPU in parallel)
- Framebuffer stores the final image that gets displayed
This same pipeline powers everything from simple 2D games to photorealistic 3D rendering engines. The difference isn't the pipeline - it's what you do in the shaders and how much geometry you process.
What Happens Next? Where to Go From Here
Now that you understand the fundamentals, you can build on them. The next steps would be:
Adding More Complexity:
- Draw multiple triangles to form shapes
- Use index buffers to share vertices between triangles
- Transform vertices with matrices (rotation, scaling, translation)
- Add a camera system (view and projection matrices)
- Load and apply textures
- Implement lighting (Phong shading, etc.)
Understanding More Concepts:
- Depth testing (rendering things in the correct order front-to-back)
- Blending (transparency and translucent objects)
- Stencil testing (for effects like reflections and shadows)
- Instancing (drawing many copies of the same object efficiently)
But here's the important part: you now understand the foundation. Everything else builds on what you've learned here. Every modern graphics API - OpenGL, DirectX, Vulkan, Metal, WebGL - uses this same basic pipeline:
Vertices → Vertex Shader → Rasterization → Fragment Shader → Pixels
The APIs might have different function names and slightly different approaches, but conceptually, they all do the same thing.
Conclusion: You Drew a Triangle!
If you've made it this far and actually run the code, congratulations! You've drawn a triangle from scratch. More importantly, you understand why it works.
The first time I got a triangle on screen, I felt like I'd performed magic. The truth is, it's not magic - it's a beautiful, logical system that transforms data through a series of well-defined stages. And now you understand those stages.
This is the beginning. Every game you've ever played, every 3D visualization you've ever seen, every GPU-accelerated application - they all started with someone drawing their first triangle. The difference between a beginner and an expert isn't knowledge - it's the number of triangles they've rendered and the number of bugs they've debugged along the way.
So go forth and render more triangles! Experiment. Break things. Fix them. Try drawing a square (hint: two triangles). Try animating it. Try adding more colors. Each experiment will deepen your understanding.
And when something doesn't work (and things won't work frequently), remember: that's not failure, that's learning. Every graphics programmer has spent hours debugging why their triangle is upside down, or invisible, or somehow rendering in a completely different window.
Welcome to graphics programming. It's frustrating, rewarding, and absolutely addictive. Now you know how the magic works.
Questions? Confused about something? That's completely normal! Graphics programming has a steep learning curve, but you've just climbed the first major hill. Everything from here builds on what you now understand.