All tutorials Mighty Professional
Build a Game Engine · Rendering: the 2D path (Vulkan)

The GPU & the Graphics Pipeline

Your first textured quad warps along its diagonal, distant walls flicker through each other, and a forest of thin grass blades costs far more to shade than its pixel count suggests. Each of those comes from how the GPU turns triangles into pixels, and Vulkan makes you configure every step of it. This page builds that mental model first: how a triangle in clip space (the vertex shader's output coordinates) becomes lit pixels in a framebuffer, the image in memory the GPU draws into. It covers why a GPU is built for throughput, the stages a triangle flows through, how the rasterizer decides which pixels are inside, and the one piece of math () that first rasterizers most often get wrong. No graphics API yet, just a software rasterizer in C++ and Rust.

Time~55 min LevelMid PrereqsHelpful, not required: the 3D Math tutorial (the model→view→projection→clip→NDC flow this page reuses). You can read C++ or Rust. StackC++ & Rust
◂ Build a Game Engine Phase 5 · Rendering: the 2D path (Vulkan) Next · Your First Triangle in Vulkan ▸
What you'll be able to do by the end

01Why a GPU is shaped this way

A 1080p frame has about two million pixels, and each one runs a small shader program, usually the same one as its neighbors. A CPU is built for the opposite job. It spends transistors fighting latency: deep caches, branch prediction, out-of-order execution (running later instructions while an earlier one waits on memory), all to finish one thread's next instruction as soon as possible. A GPU spends them on throughput: thousands of ALUs, the lanes that do the arithmetic, running the same program over huge batches of data. It hides memory latency by keeping many groups of work in flight and switching to another group whenever one is waiting[1]. The pipeline is an assembly line: stages run in sequence on any one triangle, but all stages are busy on different work at once.

The parallelism has a specific shape: (single instruction, multiple threads). NVIDIA runs threads in groups of 32 called warps, which step through one shared instruction stream together; "a warp executes one common instruction at a time," and when threads in a warp take different branches the warp "executes each branch path taken, disabling threads that are not on that path"[2]. AMD's equivalent, a wavefront, is 32 or 64 threads depending on the architecture (RDNA runs both wave32 and wave64; GCN and CDNA use 64)[3].

Worked example

1920 × 1080 is 2,073,600 pixels, or 64,800 warps of 32 for one full-screen pass, before any overdraw. Now give the pixel shader a branch: a cheap path of 10 instructions and an expensive path of 100. If all 32 threads in a warp take the cheap path, the warp issues 10 instructions. If 4 of them take the expensive path, the warp runs both paths with the other lanes switched off: 10 + 100 = 110 instructions, 11 times the cost, even though 28 threads only wanted the cheap path.

SIMT is not SIMD

They share the "one instruction, many data elements" idea, but the difference matters: SIMD exposes the vector width to your code (you write an 8-wide add), while SIMT presents a scalar-looking per-thread program and the hardware handles divergence by masking lanes[2]. And the warp/wavefront size is vendor- and architecture-specific (32 on NVIDIA; 32 or 64 on AMD), so don't hard-code "32." Divergence costs by serializing the taken paths, not a fixed multiplier.

02The logical pipeline

When Vulkan asks you for a vertex shader, a rasterization state, a depth state and a blend state, it's asking you to fill in the stages of one assembly line. A triangle flows through a fixed sequence of stages. Some are programmable (you write a shader), some are fixed-function: built into the hardware, so you pick options but can't replace the logic. In order[4]:

vertex shader→ clip / cull→ rasterize→ fragment shader→ depth / stencil→ blend→ framebuffer

The vertex shader runs once per vertex and outputs a clip-space position. After clipping, the GPU does the perspective divide (xyz / w, which makes far things smaller) to get NDC, normalized device coordinates where the visible screen runs from −1 to 1. Then the viewport transform maps NDC to framebuffer pixels. Both are fixed-function, and the 3D Math tutorial derives them, so this page only uses them. Vulkan's clip volume is 0 ≤ zc ≤ wc (depth range [0,1]), and a negative viewport height performs the Y-flip[5].

Worked example

Take the vertex the §9 widget traces: object position (1.0, 0.5, −3.0), with a 90° vertical field of view, a 16:9 aspect, near plane 0.3, far plane 100 and an 800 × 450 viewport. The vertex shader's projection outputs clip position (0.5625, 0.5, 2.71, 3.0): x is scaled by 1 ÷ 1.778 for the aspect, and w is the distance in front of the camera, 3. It's inside the clip volume, since |x| and |y| are at most w and z is between 0 and w. Dividing by w gives NDC (0.19, 0.17, 0.90). The viewport maps x to (0.1875 + 1) ÷ 2 × 800 = 475, and with the Y-flip, y to (1 − 0.167) ÷ 2 × 450 = row 187. The 0.90 goes on to the depth test.

The logical order is an as-if contract

This is the order the API pretends things happen in. Real hardware reorders for speed (early-Z, the depth test moved ahead of the shader, §7), but always in a way that's "100% functionally consistent" with this model[10]. Also: a "fragment" is a candidate pixel that depth, stencil (a small per-pixel mask tested alongside depth), and blend may still kill before it's written; OpenGL and Vulkan say fragment, Direct3D says pixel shader, same thing. In a classic Vulkan graphics pipeline only the vertex shader is mandatory (a mesh-shading pipeline uses a mesh shader instead); the fragment shader can be left out for depth-only passes, and tessellation (subdividing patches into more triangles) and geometry stages (per-primitive programs that can emit new primitives) are optional[4].

03Rasterization

Three vertices land on the screen at fractional pixel positions, and the GPU has to decide, for millions of pixels a frame, which ones the triangle covers. Rasterization is that decision. The trick, from Pineda's 1988 paper, is the : each edge defines a linear function E(x,y) that is zero on the edge's line, positive on one side and negative on the other[6]. A sample point is inside the triangle when all three edge functions agree in sign[7].

The edge function from a to b, evaluated at point p, is a 2D cross product:

eq. 1 · edge function E(p) = (p.x − a.x) × (b.y − a.y) − (p.y − a.y) × (b.x − a.x)

In words: the value is twice the signed area of the triangle formed by a, b and p. It's zero when p sits on the line through a and b, and its sign tells you which side p is on. Run it for all three edges; if all three have the same sign as the whole triangle's area, p is inside. Hover any term to see what it stands for.

Normalize the three edge values by their sum (the doubled triangle area) and you get the barycentric coordinates: three weights that add up to 1 and say how close the point is to each vertex. They interpolate any per-vertex attribute, such as a color or a texture coordinate. Each edge function changes by a constant when you step one pixel in x or y, so a hardware rasterizer can update them with adds, which is exactly why they map to cheap hardware[6].

Worked example

Take v0 = (0, 0), v1 = (0, 4) and v2 = (4, 0) in pixel coordinates, with y pointing down, and the edge function from the listing below. The doubled area is Ev0→v1(v2) = 4 × 4 − 0 × 0 = 16, positive, so this winding is drawn. Test pixel (1, 0) at its center, (1.5, 0.5). The three edge values are w0 = Ev1→v2 = 1.5 × (−4) − (−3.5) × 4 = 8, w1 = Ev2→v0 = 2 and w2 = Ev0→v1 = 6. All three are positive, so the pixel is inside, and they sum to 16, the area. Divided by 16 they give barycentrics 0.5, 0.125 and 0.375, and 0.5 × v0 + 0.125 × v1 + 0.375 × v2 = (1.5, 0.5), the sample point back again. Pixel (3, 2), at (3.5, 2.5), gets w0 = −8, so it's outside. Stepping one pixel right always changes w0 by v2.y − v1.y = −4.

The top-left rule is about determinism, not anti-aliasing

When a sample lands exactly on an edge shared by two triangles (an edge function of exactly zero), a tie-break decides which triangle owns it, so the edge is drawn exactly once: no gaps, no double-shading. Giesen: this "boils down to subtracting 1 from the constant term on some edges," which makes integer rasterizers "guaranteed watertight"[7]. And "inside" means the sample point (the pixel center by default) passes the test, not "the triangle overlaps the pixel."

The widget rasterizes a textured quad as two triangles over a coarse grid, where each 6-pixel cell stands in for one framebuffer pixel:

The quad recedes from the near edge (w = 1) to the far edge (w = 3). Drag the three solid corners (the dashed fourth follows, so the quad stays a flat parallelogram in 3D), switch to linear, and tick the 2×2 quads. Each cell's three edge functions decide coverage; their normalized values are the barycentrics that interpolate a checker pattern computed from the UVs (no texture is sampled). Perspective-correct: the checker's lines stay straight across the diagonal and bunch toward the far edge. Linear: each triangle gets its own affine map (a straight-line blend of its corners in screen space), so the lines kink along the shared diagonal (§5). With the 2×2 grid on, the amber cells are helper pixels (§4): shaded for the derivatives, then thrown away. Both triangles pay for them along the diagonal, and the readout counts them: about 6% of shaded lanes at the starting shape on a desktop-width page, and about 12% on a phone, where the same quad covers fewer cells.

04The 2×2 quad

A field of grass blades a pixel or two wide can cost several times more to shade than the same number of pixels on a wall. Part of the reason is that hardware doesn't shade pixels one at a time; it shades them in 2×2 quads. The quads exist for texture filtering. Choosing a mip level, one of the texture's precomputed half-size copies, needs the screen-space derivatives of the texture coordinates (ddx/ddy): how much they change from one pixel to the next. The cheapest way to estimate those is to subtract a pixel's value from its neighbor's within the quad[8].

Worked example

A 1024-texel-wide texture is drawn so that u grows by 1/256 from one pixel to the next. Subtracting neighbors in the quad gives ddx(u) = 1/256, and 1/256 × 1024 = 4 texels per pixel step. Roughly, the mip level is log₂ of that footprint, so the sampler reads mip 2, the 256-wide copy, where one pixel step is one texel. Without the neighbor's value there's no difference to take, which is why all four lanes of the quad have to run.

Because all four lanes are needed for that finite difference, even pixels outside the triangle in an edge quad get shaded; their output is discarded. These are helper pixels, and Giesen puts the cost at "between 25-75% of the shading work for quads generated for triangle edges"[8]. A triangle that covers a single pixel still shades a whole quad: 4 lanes for 1 pixel, 75% wasted. A vertical sliver one pixel wide and 10 pixels tall, lined up inside one column of quads, touches 5 quads, so it shades 20 lanes for 10 pixels. Quad overshading is one of the main reasons small and thin triangles shade inefficiently (fixed per-triangle setup cost is another), and one reason engines use LODs, simpler versions of a mesh swapped in as it shrinks on screen, to keep triangles from shrinking to a few pixels.

05Perspective-correct interpolation

You draw a floor as a textured quad, two triangles, and the texture bends along the diagonal where they meet. The cause is interpolating in the wrong space. Perspective projection keeps straight lines straight but doesn't preserve distances: the far half of the floor covers fewer pixels than the near half. So a value that's linear across a surface in 3D is not linear across its projected image on screen, and interpolating an attribute with screen-space barycentrics directly is wrong for anything that isn't already screen-linear[9].

Worked example

One edge of the floor runs from a near vertex (w = 1, u = 0) to a far vertex (w = 3, u = 1). At the pixel halfway between them on screen, plain linear interpolation says u = 0.5. Do it in the right space instead: u ÷ w goes from 0 to 1/3, so halfway it's 1/6; 1 ÷ w goes from 1 to 1/3, so halfway it's 2/3. Dividing, u = (1/6) ÷ (2/3) = 0.25. The middle of the screen shows only a quarter of the way along the edge in 3D: the near half of the pixels covers the first quarter of the texture, which is the foreshortening you expect from a floor.

The fix: interpolate attribute / w and 1 / w linearly (those are screen-linear), then divide at each pixel to recover the attribute:

eq. 2 · perspective-correct interpolation u = (Σ λᵢ × uᵢ ÷ wᵢ) ÷ (Σ λᵢ ÷ wᵢ)

In words: blend each vertex's attribute divided by its w with the ordinary screen-space weights, blend the 1 ÷ w values the same way, and divide the first result by the second. Vertices farther away (bigger w) count for less, which undoes the squashing perspective did to the far side. Hover any term to see what it stands for.

The software rasterizer below is built around that divide:

A software triangle rasterizer (perspective-correct)
struct Vertex {            // already in screen space (post viewport transform)
    float screenX, screenY;   // pixel coordinates
    float oneOverW;           // 1 / clip-space w, precomputed per vertex
    float uOverW, vOverW;     // the attribute (u,v) already divided by w
};

// Edge function: signed doubled area of (a, b, p). Pineda's half-plane test.
static float edge(const Vertex& a, const Vertex& b, float px, float py) {
    return (px - a.screenX) * (b.screenY - a.screenY)
         - (py - a.screenY) * (b.screenX - a.screenX);
}

void rasterize(Vertex v0, Vertex v1, Vertex v2, uint32_t* framebuffer, int width, int height) {
    float area = edge(v0, v1, v2.screenX, v2.screenY);   // doubled signed area; sign = winding
    if (area <= 0) return;   // 0: degenerate. < 0: the other winding, culled like a back face
    float invArea = 1.0f / area;

    for (int y = 0; y < height; ++y)
    for (int x = 0; x < width; ++x) {
        float sx = x + 0.5f, sy = y + 0.5f;     // sample the pixel center
        float w0 = edge(v1, v2, sx, sy);             // the three edge functions
        float w1 = edge(v2, v0, sx, sy);
        float w2 = edge(v0, v1, sx, sy);
        if (w0 < 0 || w1 < 0 || w2 < 0) continue;  // inside iff all three share area's (positive) sign

        float l0 = w0 * invArea, l1 = w1 * invArea, l2 = w2 * invArea;  // barycentrics

        // Interpolate 1/w and attr/w linearly (these ARE screen-linear)...
        float invW = l0*v0.oneOverW + l1*v1.oneOverW + l2*v2.oneOverW;
        float uW   = l0*v0.uOverW   + l1*v1.uOverW   + l2*v2.uOverW;
        float vW   = l0*v0.vOverW   + l1*v1.vOverW   + l2*v2.vOverW;
        // ...then divide at the pixel to recover the true attribute.
        float u = uW / invW, v = vW / invW;
        framebuffer[y * width + x] = shade(u, v);
    }
}
#[derive(Clone, Copy)]
struct Vertex {            // already in screen space (post viewport transform)
    screen_x: f32, screen_y: f32,   // pixel coordinates
    one_over_w: f32,               // 1 / clip-space w, precomputed per vertex
    u_over_w: f32, v_over_w: f32,   // attribute (u,v) already divided by w
}

// Edge function: signed doubled area of (a, b, p). Pineda's half-plane test.
fn edge(a: &Vertex, b: &Vertex, px: f32, py: f32) -> f32 {
    (px - a.screen_x) * (b.screen_y - a.screen_y)
        - (py - a.screen_y) * (b.screen_x - a.screen_x)
}

fn rasterize(v0: &Vertex, v1: &Vertex, v2: &Vertex, framebuffer: &mut [u32], width: usize, height: usize) {
    let area = edge(v0, v1, v2.screen_x, v2.screen_y);   // doubled signed area; sign = winding
    if area <= 0.0 { return; }   // 0: degenerate. < 0: the other winding, culled like a back face
    let inv_area = 1.0 / area;

    for y in 0..height {
        for x in 0..width {
            let (sx, sy) = (x as f32 + 0.5, y as f32 + 0.5);   // pixel center
            let w0 = edge(v1, v2, sx, sy);
            let w1 = edge(v2, v0, sx, sy);
            let w2 = edge(v0, v1, sx, sy);
            if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 { continue; }   // inside iff all three share area's sign

            let (l0, l1, l2) = (w0 * inv_area, w1 * inv_area, w2 * inv_area);   // barycentrics
            // Interpolate 1/w and attr/w linearly, then divide at the pixel.
            let inv_w = l0*v0.one_over_w + l1*v1.one_over_w + l2*v2.one_over_w;
            let u_w   = l0*v0.u_over_w   + l1*v1.u_over_w   + l2*v2.u_over_w;
            let v_w   = l0*v0.v_over_w   + l1*v1.v_over_w   + l2*v2.v_over_w;
            let (u, v) = (u_w / inv_w, v_w / inv_w);
            framebuffer[y * width + x] = shade(u, v);
        }
    }
}
What's intentionally missing

No depth buffer (every covered pixel is written; add an interpolated depth and a z-buffer for occlusion, §6). No 2×2-quad granularity or derivatives, so no real mip selection (§4). No near-plane clipping (vertices with w ≤ 0 produce garbage; the real pipeline clips first, §2). No sub-pixel fixed-point or the proper integer top-left tie-break. No MSAA, single-threaded and scalar (no SIMT, no tiling). The viewport transform that produced screenX/Y and oneOverW, and shade(), are assumed done upstream.

One subtlety: depth (z in NDC, which is already divided by w) is the special case that is screen-linear after projection, so the depth buffer interpolates it linearly with no divide. It's the other attributes that need the perspective-correct divide.

06The depth buffer

A character walks behind a wall, but the character was drawn second, so without help it paints right over the wall. A per-pixel fixes that. It stores the closest depth seen so far at each pixel; each fragment compares its depth to the stored value and is kept or discarded. This resolves visibility independent of draw order, which is why you don't have to sort opaque geometry back-to-front.

The stored depth isn't distance. With Vulkan's [0,1] range, a point at distance z between the near plane n and far plane f stores:

eq. 3 · standard depth mapping depth = f ÷ (f − n) × (1 − n ÷ z)

In words: depth climbs from 0 at the near plane toward 1 at the far plane, but it follows 1 ÷ z, so it climbs steeply close to the camera and barely moves far away. Most of the buffer's values go to the space just past the near plane. Hover any term to see what it stands for.

Worked example

Near plane 0.1 m, far plane 1000 m, a 24-bit depth buffer with 224 evenly spaced values. Depth reaches 0.5 at z = 0.2 m: half of all the values describe the first 20 cm in front of the camera. At 500 m, neighboring values are 500² × (1000 − 0.1) ÷ (1000 × 0.1 × 224) = 0.15 m apart, so a wall and a poster 10 cm in front of it round to the same depth and fight. Flip the mapping (near → 1, far → 0) and store a 32-bit float, and the depth at 500 m is about 0.0001, where floats are packed far more densely: representable values there are a fraction of a millimetre apart. Rounding in the projection math becomes the real limit then, and Reed's measurements show the result stays far more even out to the far plane[11].

Reversed-Z, and what it's not

A plain [0,1] depth buffer wastes precision: the 1/z mapping bunches values near the camera, so far geometry z-fights, flickering as two surfaces at nearly equal depths trade places. The fix is reversed-Z (map near→1, far→0) with a float depth buffer, whose distribution nearly cancels the 1/z nonlinearity; Reed's advice is blunt: "in any perspective projection situation, just use a floating-point depth buffer with reversed-Z"[11]. Reversed-Z is orthogonal to Vulkan's [0,1] range (which you already have): it's the additional near↔far swap, plus flipping the depth compare to GREATER and clearing to 0. And z-fighting has several causes (equal depths, too little precision, coplanar decals), so it's not "a bug in the depth test."

The widget draws two overlapping triangles, always blue first, then red, with blue the nearer one:

The readout gives each triangle's stored depth, red 0.50 and blue 0.20 at the start, compared with LESS (smaller is nearer). Untick the depth test, drag Depth gap to the left, then tick reversed-Z. With the depth test off, the later-drawn red triangle covers the nearer blue one (the painter's-order artifact). Turn it on and blue occludes correctly, whatever the submission order. Close the gap until the two depths are less than 0.02 apart and the overlap breaks into a flickering noise pattern: z-fighting, where rounding decides the winner pixel by pixel. The strip shows how far apart representable depths are from near to far; the reversed-Z toggle evens them out, which is what keeps distant surfaces from fighting.

07Early-Z

A room's walls, furniture and characters overlap, so many pixels get covered several times a frame. The API says depth testing happens after the fragment shader, but shading a pixel only to discard it for being behind a wall is wasteful. So hardware runs the depth test early, before the shader, whenever it can prove that gives the same result[10].

Worked example

A 1080p view has three opaque layers over every pixel: a character, the wall behind it, the sky behind that. Without early-Z the fragment shader runs 3 × 2,073,600 = 6.2 million times and two thirds of the results are thrown away. Draw front to back with early-Z working and the character fills the depth buffer first, so the wall and sky fail the early test and only about 2.1 million fragments are shaded. Draw back to front and every layer is nearer than the last, so all 6.2 million still run. That's why engines sort opaque draws roughly front to back, or lay down depth in a cheap pre-pass first.

What limits early-Z

Early-Z is an optimization, not a stage you can rely on, and it has two halves. When the fragment shader writes depth (gl_FragDepth), the early test itself is off, because the depth being tested doesn't exist until the shader runs. When it uses discard, alpha-test (killing a fragment whose alpha is below a threshold), or alpha-to-coverage (turning alpha into a per-sample coverage mask), the hardware can still reject occluded fragments early, but it can't write depth early, because the shader may still kill the sample; Giesen: "It's only writes that we have to be careful with"[10]. (A texture read or an ordinary branch changes neither.) There's an escape hatch for depth output: conservative depth output (layout(depth_greater)) lets a shader write depth and keep early rejection by promising the change only goes one way, for example never nearer.

08Immediate-mode vs tile-based

A post-process pass that runs fine on a desktop card can be far slower on a phone. A common reason is memory bandwidth, which mobile GPUs are built to avoid spending. There are two broad GPU architectures, and the split decides how you structure a frame on mobile.

Worked example

A 16 × 16 tile with 4-byte color and 4-byte depth needs 256 × 8 = 2,048 bytes, small enough to live on-chip while every triangle touching the tile is drawn. The full 1080p frame at the same 8 bytes per pixel is 16.6 MB. Split one render pass into two so the second can sample the first, and the tiler has to store that 16.6 MB to main memory and load it back: about 33 MB of extra traffic per frame, roughly 2 GB/s at 60 fps, spent on nothing but the split.

The engine consequence on mobile

On a tile-based GPU, ending a render pass early so a later draw can sample what it just rendered forces every tile out to main memory and back in, which throws away the bandwidth win. So you structure passes to avoid it: Vulkan subpasses with input attachments (or, in Vulkan 1.4, dynamic rendering's local reads) are the sanctioned on-tile read, and load/store ops that don't need the old or the finished contents skip the memory traffic entirely. Two scoping notes: full tile-based deferred rendering is the mobile (and Apple silicon) design, not how desktop GPUs work; and TBDR's "deferred" (defer shading until visibility) is a different thing from deferred shading (the G-buffer renderer, a later tutorial).

09The baked pipeline

A game hitches the first time a new material appears on screen. A common cause is the driver compiling GPU code for that material mid-frame, and the thing it compiles is a baked pipeline. Everything above (which shaders run, the rasterizer state, the depth compare op, the blend equation, the viewport) is configuration. The explicit APIs freeze it into one object. Vulkan: "each pipeline is controlled by a monolithic object created from a description of all of the shader stages and any relevant fixed-function stages"[4], which it can then optimize as a whole and bind in one call.

That's the mental model for the next tutorial: you don't set pipeline state one stage at a time, you describe the whole pipeline once, compile it, and bind it. (Modern Vulkan adds dynamic state and shader objects that relax the bake-everything rule, and OpenGL's global state machine is the counterexample, so this is the explicit-API model, not a universal law.)

Worked example

An engine has 10 shader pairs, 3 blend modes (opaque, alpha, additive), 2 depth setups (test-and-write, test-only) and 2 cull modes. If all of those are baked state, the full set is 10 × 3 × 2 × 2 = 120 pipeline objects to compile. That's why engines compile pipelines ahead of time or cache them on disk, rather than creating one the first time a material is drawn, and why the dynamic state above exists: each piece of state made dynamic stops multiplying the count.

The widget walks one vertex through the stages so the data at each step is concrete:

The list on the left is the pipeline and the panel shows the vertex's data at the current stage. Press Step or Play, then tick "skip the Y-flip". Each step shows the vertex's data with real numbers (90° vertical field of view, 16:9, near plane 0.3, far 100, an 800×450 viewport), the ones worked through in §2: object space, clip space after the shader, NDC after the divide, framebuffer pixels after the viewport transform, then coverage, shading, and the depth test. Tick "skip the Y-flip" and the pixel lands on row 262 instead of 187, with the triangle upside down: the Vulkan Y-down trap from the 3D Math tutorial, made concrete here in the pipeline.

Wrong answers, and why: the seam warp is an interpolation error (not z-fighting or winding); and SIMT vs SIMD is about software-visible width and divergence handling, not a fixed lane count or an inability to branch.

10Pitfalls

Texture warps along a seamLinear screen-space interpolation; interpolate attr/w and 1/w, divide per pixel.
Hard-coding warp = 32Vendor-specific: 32 on NVIDIA, 32 or 64 on AMD. Query it; don't assume.
"Depth needs perspective correction"NDC depth is screen-linear; it's attributes that need the divide.
discard kills performancediscard and alpha-test delay depth writes until after the shader; writing depth turns the early test off. Use opaque paths where you can.
Thin triangles are slow2×2-quad helper lanes waste 25–75% of edge shading; manage triangle density.
Far-plane z-fighting1/z bunches precision near the camera; use reversed-Z with a float depth buffer.
Splitting passes on mobileSampling what a pass just drew forces a round-trip through main memory on a tiler; use subpasses/input attachments instead.

11What's next

With the model in hand, the next tutorial writes it for real: Your First Triangle in Vulkan, where this baked pipeline becomes a VkPipeline, the rasterizer and depth test become pipeline state, and the surface from the Platform & Window tutorial gets a swapchain, the rotating set of images the display shows. Then textures, then a real 2D renderer. The full path is on the series hub.

  1. Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 2–3. realtimerendering.com. The conceptual four-stage pipeline and the throughput-oriented GPU.
  2. NVIDIA. CUDA Programming Guide, §3.2.2.1 "SIMT Execution Model." docs.nvidia.com. Warp = 32 threads, branch divergence serializes paths, and the SIMT-vs-SIMD distinction (software-visible width).
  3. AMD. ROCm/HIP hardware docs and the RDNA Architecture whitepaper. gpuopen.com. Wavefront size is 32 or 64 depending on architecture (RDNA vs GCN/CDNA).
  4. The Khronos Group. Vulkan Specification, "Pipelines." docs.vulkan.org. The stage order and the "monolithic" pipeline object baking shader and fixed-function state.
  5. The Khronos Group. Vulkan Specification, vertex post-processing and VkViewport. docs.vulkan.org. The clip volume 0 ≤ z ≤ w, the perspective divide, and the negative-height Y-flip.
  6. Juan Pineda. "A Parallel Algorithm for Polygon Rasterization." SIGGRAPH 1988. dl.acm.org. The linear edge function / half-plane test, computed incrementally.
  7. Fabian Giesen. "A trip through the Graphics Pipeline 2011, part 6." fgiesen.wordpress.com. Edge-test coverage, the watertight top-left rule, and 2×2 quads.
  8. Fabian Giesen. "A trip through the Graphics Pipeline 2011, part 7." fgiesen.wordpress.com. Early-Z as an as-if-consistent optimization and the features that disable it.
  9. Fabian Giesen. "A trip through the Graphics Pipeline 2011, part 8." fgiesen.wordpress.com. Helper pixels, derivatives via finite differencing, and 25–75% edge-quad waste.
  10. Scratchapixel. "Perspective-Correct Interpolation and Vertex Attributes." scratchapixel.com. Why screen-linear interpolation is wrong and the interpolate-attr/w-then-divide fix.
  11. Nathan Reed. "Depth Precision Visualized." reedbeta.com. The 1/z precision problem and reversed-Z with a float depth buffer.
  12. Peter Harris (Arm). "The Mali GPU: An Abstract Machine, Part 2 - Tile-based Rendering." 2014. developer.arm.com. The two-pass tile-based architecture, 16×16 tiles, and the on-chip color/depth/stencil working set.
  13. Imagination Technologies. PowerVR Architecture guide, "Tile-Based Deferred Rendering (TBDR)." docs.imgtec.com. TBDR defers texturing and shading until visibility is resolved; hidden-surface removal.
  14. David Kanter. "Tile-based Rasterization in Nvidia GPUs." Real World Tech, 2016. realworldtech.com. Maxwell and Pascal use tile-based immediate-mode rasterizers that buffer pixel output on-chip.

See also