computer-science7 min read

Computer Graphics Tutorial: Learn Graphics from Scratch (2026)

Computer Graphics Tutorial: Learn Graphics from Scratch (2026)

Published:  |  Category: Computer Science  |  Reading time: ~15 min
Computer Graphics Tutorial: Learn Graphics from Scratch (2026)

Computer graphics transforms mathematical models into visual images, powering everything from video games and movies to scientific visualization and user interfaces. My journey through graphics programming — from rasterizing triangles to writing GLSL shaders — has given me a deep appreciation for the interplay between geometry, optics, and hardware. This tutorial covers the graphics pipeline, transformations, rasterization, shading, and GPU architecture.

We will implement a software rasterizer to understand the fundamentals before moving to GPU-accelerated rendering with OpenGL and shader programming. Each concept is tied to visual output: you will see how matrices move objects, how the depth buffer resolves visibility, and how lighting models create realistic surfaces.

The Graphics Pipeline

The graphics pipeline transforms 3D scene data into 2D pixel output. The stages are: vertex processing (transform vertices from model space to screen space via world, view, and projection matrices), rasterization (convert primitives into fragments covering pixels), fragment processing (compute per-pixel color via shading, texturing, and lighting), and output merging (blend fragments with the framebuffer using depth/stencil testing). The modern programmable pipeline replaces fixed-function stages with vertex and fragment shaders written in GLSL or HLSL.

# Software rasterizer: simplified pipeline

def render_triangle(v0, v1, v2, framebuffer, depth_buffer):
    # Vertex transformation
    world = multiply_matrix(v0, model_matrix)
    view = multiply_matrix(world, view_matrix)
    clip = multiply_matrix(view, projection_matrix)
    ndc = clip / clip.w  # perspective divide
    screen = (ndc + 1) * 0.5 * vec2(width, height)

    # Rasterization: bounding box + barycentric coordinates
    min_x = max(0, min(screen.x, screen.y, screen.z))
    max_x = min(width - 1, max(screen.x, screen.y, screen.z))
    for x in range(int(min_x), int(max_x) + 1):
        for y in range(int(min_y), int(max_y) + 1):
            if barycentric_inside(screen, x, y):
                z = interpolate_depth(screen, x, y)
                if z < depth_buffer[x][y]:
                    depth_buffer[x][y] = z
                    framebuffer[x][y] = shade_fragment(x, y)

Transformations: Translation, Rotation, Scaling

3D transformations are represented as 4x4 homogeneous matrices, enabling translation (which requires an extra dimension) to be combined with rotation and scaling through matrix multiplication. A translation matrix moves objects by (tx, ty, tz). Rotation matrices around x, y, z axes use sine and cosine. Scaling matrices stretch or shrink along axes. Composing transformations T * R * S applies scaling first, then rotation, then translation (order matters because matrix multiplication is not commutative). The view matrix positions the camera; the projection matrix defines the viewing frustum.

import numpy as np

def translate(tx, ty, tz):
    return np.array([
        [1, 0, 0, tx],
        [0, 1, 0, ty],
        [0, 0, 1, tz],
        [0, 0, 0, 1]
    ])

def rotate_x(angle):
    c, s = np.cos(angle), np.sin(angle)
    return np.array([
        [1, 0, 0, 0],
        [0, c, -s, 0],
        [0, s, c, 0],
        [0, 0, 0, 1]
    ])

def perspective(fov, aspect, near, far):
    f = 1.0 / np.tan(fov / 2)
    return np.array([
        [f/aspect, 0, 0, 0],
        [0, f, 0, 0],
        [0, 0, (far+near)/(near-far), (2*far*near)/(near-far)],
        [0, 0, -1, 0]
    ])

# Model matrix: scale -> rotate -> translate
model = translate(0, 0, -5) @ rotate_x(0.5) @ np.diag([2, 2, 2, 1])

Rasterization and the Depth Buffer

Rasterization determines which pixels a primitive covers. For triangles, this involves computing barycentric coordinates (alpha, beta, gamma) where a point inside the triangle has all coordinates between 0 and 1 summing to 1. The depth buffer (z-buffer) stores the closest depth value for each pixel. When a fragment is rasterized, its interpolated depth is compared against the stored value — if closer, it replaces the framebuffer color and updates the depth buffer. This simple algorithm correctly handles overlapping opaque objects without sorting.

def barycentric(A, B, C, P):
    # Returns (alpha, beta, gamma) for point P relative to triangle ABC
    v0 = B - A
    v1 = C - A
    v2 = P - A
    d00 = np.dot(v0, v0)
    d01 = np.dot(v0, v1)
    d11 = np.dot(v1, v1)
    d20 = np.dot(v2, v0)
    d21 = np.dot(v2, v1)
    denom = d00 * d11 - d01 * d01
    beta = (d11 * d20 - d01 * d21) / denom
    gamma = (d00 * d21 - d01 * d20) / denom
    alpha = 1.0 - beta - gamma
    return alpha, beta, gamma

def is_inside(alpha, beta, gamma):
    return alpha >= 0 and beta >= 0 and gamma >= 0

# Depth interpolation
z = alpha * A.z + beta * B.z + gamma * C.z

Lighting and Shading Models

Lighting models approximate how light interacts with surfaces. Ambient light provides uniform base illumination. Diffuse (Lambertian) reflection scatters light equally in all directions — intensity depends on the dot product of the surface normal and light direction. Specular (Phong) reflection creates highlights based on the angle between the view direction and the reflected light vector. The Blinn-Phong model uses the halfway vector between light and view for efficiency. Gouraud shading computes lighting at vertices and interpolates across fragments; Phong shading interpolates normals and computes lighting per-pixel for sharper highlights.

# Blinn-Phong fragment shader (GLSL-like)

uniform vec3 lightPos;
uniform vec3 viewPos;
uniform sampler2D diffuseMap;

in vec3 fragNormal;
in vec2 fragUV;
in vec3 fragWorldPos;

out vec4 fragColor;

void main() {
    vec3 N = normalize(fragNormal);
    vec3 L = normalize(lightPos - fragWorldPos);
    vec3 V = normalize(viewPos - fragWorldPos);
    vec3 H = normalize(L + V);

    vec3 albedo = texture(diffuseMap, fragUV).rgb;
    float ambient = 0.1;
    float diffuse = max(dot(N, L), 0.0);
    float specular = pow(max(dot(N, H), 0.0), 32.0);

    vec3 result = (ambient + diffuse) * albedo + specular * vec3(1.0);
    fragColor = vec4(result, 1.0);
}

Texture Mapping and Filtering

Texture mapping wraps 2D images onto 3D surfaces by associating (u,v) texture coordinates with each vertex. During rasterization, u,v coordinates are interpolated across fragments and used to sample the texture. Bilinear filtering blends the four nearest texels for smoother results. Mipmapping pre-computes downsampled versions of the texture; the GPU selects the appropriate level based on the fragment's screen-space footprint (anisotropic filtering handles non-uniform scaling). Texture atlases pack multiple textures into one image to reduce draw calls.

# Bilinear texture filtering

def bilinear_sample(texture, u, v):
    w, h = texture.shape[1], texture.shape[0]
    x = u * (w - 1)
    y = v * (h - 1)
    x0, y0 = int(x), int(y)
    x1, y1 = min(x0 + 1, w - 1), min(y0 + 1, h - 1)

    fx, fy = x - x0, y - y0
    top = texture[y0, x0] * (1 - fx) + texture[y0, x1] * fx
    bot = texture[y1, x0] * (1 - fx) + texture[y1, x1] * fx
    return top * (1 - fy) + bot * fy

# Without mipmapping, minified textures cause aliasing (moire patterns)
# Mip level selection: lambda = log2(max(|du/dx|, |dv/dx|, |du/dy|, |dv/dy|))

GPU Architecture and Parallelism

GPUs achieve massive parallelism through thousands of small cores organized into streaming multiprocessors (SMs). Warps (32 threads) execute in lockstep on NVIDIA hardware — divergent branches serialize execution. The memory hierarchy includes global memory (high latency, large), shared memory (fast, small, programmer-managed), and registers. Texture units perform filtered lookups, blending, and compression. Modern GPUs include ray tracing cores (RT cores) for hardware-accelerated ray-triangle intersection. Understanding occupancy (active warps per SM) and memory coalescing (adjacent threads accessing adjacent memory) is key to GPU performance optimization.

# CUDA kernel for parallel vector addition

__global__ void vec_add(float* A, float* B, float* C, int N) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < N) {
        C[idx] = A[idx] + B[idx];
    }
}

// Launch configuration: 256 threads per block, enough blocks to cover N
int threads = 256;
int blocks = (N + threads - 1) / threads;
vec_add<<>>(d_A, d_B, d_C, N);

// Memory coalescing: thread i accesses element i (adjacent = coalesced)
// Shared memory tiling for matrix multiply reduces global memory traffic

Frequently Asked Questions

What is the difference between rasterization and ray tracing?

Rasterization projects triangles to the screen, then determines visibility per-pixel via the depth buffer. It is fast but handles reflections and shadows poorly. Ray tracing simulates physical light paths by shooting rays from the camera, naturally handling global illumination at higher computational cost.

Why are matrices 4x4 in 3D graphics?

Homogeneous coordinates add a fourth component w, enabling translation (which is not linear in 3D) to be represented as a matrix multiplication. Perspective projection also uses the w component for the divide: clip.xyz / clip.w produces normalized device coordinates.

What is a shader and how does it differ from a regular program?

A shader is a small program that runs on the GPU for each vertex or fragment. Vertex shaders transform vertices; fragment shaders compute pixel colors. Shaders execute in massive parallel on thousands of cores but have constraints: no arbitrary memory access, limited recursion, and explicit I/O.

How does anti-aliasing work?

Aliasing occurs because a pixel samples only one point. SSAA (Super-Sampling Anti-Aliasing) renders at higher resolution and downsamples — expensive but high quality. MSAA (Multi-Sample Anti-Aliasing) computes fragment shading once per pixel but evaluates depth/stencil at multiple sample positions. FXAA and TAA are post-process techniques.

Originally published on Ayodhyyya. Last updated June 1, 2026.