Lab 2.1 — Hello Triangle, Twice
← Course 4 syllabus · Module 2 · Prev: « Lab 1.5 · Next: Lab 2.2 »
Goal
Draw the first triangle — twice, once per API — and in doing so learn the programmable pipeline end to end: what a vertex shader and fragment shader each own, what the fixed-function rasterizer does between them, and why both modern APIs bake nearly all of that configuration into an immutable pipeline state object created up front. The design insight is the lab’s real subject: OpenGL let you flip any state at any draw and made the driver re-validate (and sometimes re-compile shaders) mid-frame; Vulkan and Metal move shader compilation and state validation to build/load time, so a draw call is a cheap bind of something already proven valid. You will feel this directly — pipeline creation is the expensive call, the draw is nearly free — and you will write your first shaders in both dialects, GLSL and MSL, which is deliberate pedagogy: the two-dialect habit is what makes the dual-backend engine of Module 4 possible.
No vertex buffers yet. The triangle is hardcoded in the vertex shader, indexed by the built-in vertex ID (gl_VertexIndex in Vulkan GLSL, [[vertex_id]] in MSL) — isolating the pipeline machinery from resource machinery, which is Lab 2.2’s job.
Recommended reading
- Vulkan Tutorial (vulkan-tutorial.com) — the “Graphics pipeline basics” sequence: shader modules, fixed functions, and pipeline conclusion. This is the densest stretch of the tutorial; read it before coding, then treat the lab’s dynamic-rendering variant as the modernization.
- vkguide.dev — the graphics-pipeline sections of the new chapters, for the 1.3-era shape (dynamic rendering, dynamic viewport/scissor) this course actually uses.
- MbT — the rendering-pipeline chapters: the ones introducing the vertex/fragment stages,
MTLRenderPipelineDescriptor, and the first MSL shaders (title-level references; 5th-ed. numbering — confirm against the copy in hand). - Halladay — the opening chapters on what vertex and fragment shaders are and the first-shader walkthroughs. API-agnostic and the best plain-language telling of the mental model; read it once here and it pays through Module 3.
Prerequisites
Project & environment setup
- Vulkan:
engine/vulkan/grows shader-module and pipeline-builder helpers; new lab executablelabs/lab-2-1/→vk_triangle. GLSL sources live inshaders/and compile to SPIR-V at build time via a CMake custom command — this is one of the few code fragments this course hands you, because it is build scaffolding, not rendering:
find_program(GLSLC glslc HINTS $ENV{VULKAN_SDK}/bin REQUIRED)
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/shaders/${name}.spv
COMMAND ${GLSLC} --target-env=vulkan1.2 -O
${CMAKE_SOURCE_DIR}/shaders/${name} -o ${CMAKE_BINARY_DIR}/shaders/${name}.spv
DEPENDS ${CMAKE_SOURCE_DIR}/shaders/${name})Wrap it in a compile_shader() function and make the lab target depend on the .spv outputs, so touching a .glsl file rebuilds exactly like touching a .cpp file. - Metal: Xcode project metal-swift/MetalTriangle/ in the Lab 0.4 style; .metal files compiled by Xcode into the default library — no build integration to write, which is itself a data point for the comparison table.
Where results go:
| Artifact | Path |
|---|---|
| Notes, creation-vs-draw timings, postmortems | labs/lab-2-1/notes.md |
Screenshots (both APIs, side by side), .gputrace |
labs/lab-2-1/captures/ |
Background
The pipeline this lab exercises: vertex fetch (empty — no buffers), vertex shader (runs once per vertex; must output a clip-space position), primitive assembly and the rasterizer (clipping, perspective divide, viewport transform, back-face culling, and generation of fragments), fragment shader (runs once per covered sample; outputs color), and the output merger writing the swapchain image. Geometry and tessellation stages exist in the API but are off the table for this module — MoltenVK does not support geometry shaders and has limited tessellation support, and nothing here needs them.
Why immutable pipelines. A pipeline object is the shader stages plus the fixed-function state (vertex input layout, topology, rasterizer state, blend state, attachment formats) validated and compiled together, once. Everything the driver would otherwise have to check or re-specialize at draw time is settled at creation: draw-time cost collapses to binding. The corollary you’ll measure: creation is milliseconds, a draw is microseconds or less — three orders of magnitude apart, and the reason real engines create pipelines at load (and cache them; see Going further).
Where the two APIs put the state is the lab’s comparison. Vulkan’s VkGraphicsPipelineCreateInfo gathers roughly a dozen substructures — shader stages, vertex input, input assembly, viewport, rasterization, multisample, depth-stencil, color blend, dynamic state, and (with dynamic rendering) the attachment formats via VkPipelineRenderingCreateInfo. Metal’s MTLRenderPipelineDescriptor is leaner — functions, vertex descriptor, attachment pixel formats, blending — because Metal leaves some state on the encoder as dynamic by design: cull mode, winding, viewport, and fill mode are MTLRenderCommandEncoder calls, where Vulkan bakes them into the pipeline unless you explicitly list them as dynamic state. Same machine, different opinion about what deserves to be immutable.
Interpolation. Whatever the vertex shader outputs beyond position is interpolated across the triangle by the rasterizer — perspective-correct barycentric interpolation — and arrives in the fragment shader. Per-vertex colors make this visible: three colored corners, a smoothly blended interior, no code in between. The interface must match across the stage boundary (location/type in GLSL, the stage-in struct in MSL) — one of the postmortems breaks this on purpose.
Tasks
Vulkan (C++20)
- Build integration. Add the
compile_shader()CMake function above; confirm a deliberate GLSL syntax error fails the build with a file/line message — shader compilation is now part of compilation, which is the point. - The GLSL pair. Write a vertex shader that selects one of three hardcoded clip-space positions and colors by
gl_VertexIndex, and a fragment shader that receives the interpolated color and writes it out. (What they must do is stated; the writing is yours.) Keep clip-space Y in mind — Vulkan’s clip space is Y-down, so reason about where you expect the first vertex to land before you run. - Pipeline creation. Extend
engine/vulkan/with a pipeline builder: load the two.spvblobs into shader modules, fill every substructure deliberately (no copy-paste you can’t explain), use dynamic rendering’sVkPipelineRenderingCreateInfofor the color-attachment format, declare viewport and scissor as dynamic state, culling on, and record innotes.mdone sentence per substructure on what it pins down. - Draw. Inside Lab 0.3’s frame loop: begin rendering, bind the pipeline, set viewport/scissor,
vkCmdDraw(3, 1, 0, 0), end. Validation-clean, resize still works (dynamic viewport means no pipeline recreation on resize — say why innotes.md). - Time it. Wrap pipeline creation and the draw record in Tracy zones; record both.
Metal (Swift)
- The MSL pair. Same triangle, same colors: a vertex function indexed by
[[vertex_id]]returning a position-plus-color struct, and a fragment function consuming it. Metal’s NDC is Y-up — so the same hardcoded coordinates that were “right side up” under Vulkan land differently here. Make both windows match visually and write down which coordinates you changed and why; Lab 2.3 turns this from a patch into a policy. - Pipeline state. Build the
MTLRenderPipelineDescriptor(functions from the default library, the view’s pixel format), create theMTLRenderPipelineStateat app startup — never per frame — and note that the compile happens inside that call. Draw three vertices from the encoder. - Side by side. Run both apps; screenshot the pair for
captures/. They should be indistinguishable.
Break it on purpose (postmortem × 3)
- Winding vs. culling. Reverse the triangle’s vertex order with back-face culling on. Record the symptom — and note carefully that this one is silent: no validation error, no crash, just nothing drawn. Culled geometry is correct behavior, which is why the frame debugger, not the validation layer, is the tool that catches it (find the culled triangle in a capture before you fix it). Note where each API sets winding and cull mode (pipeline vs. encoder).
- Stage-interface mismatch. Change the fragment shader’s input to disagree with the vertex output (location or type). Record where each API catches it — Vulkan’s validation layer at pipeline creation vs. Metal at pipeline-state creation — and the exact message.
- Negative viewport height. Vulkan only: set a negative viewport height with a compensating
yoffset (legal sinceVK_KHR_maintenance1, core 1.1) and observe the triangle flip and the effective winding flip take out the triangle under culling. This is one of the two standard Y-flip strategies; Lab 2.3 chooses between them deliberately. Restore everything; validation-clean at commit.
Deliverable & expected results
vk_triangleandMetalTriangleeach showing the identical interpolated-color triangle; screenshots side by side incaptures/; all three postmortems written up innotes.mdwith exact messages (or the documented silence) and one-sentence causes.
| Quantity | Predicted | Measured |
|---|---|---|
| Graphics-pipeline creation time (Vulkan, Tracy zone) | milliseconds-scale — a compile is happening | … |
MTLRenderPipelineState creation time |
same order — milliseconds, not microseconds | … |
| CPU cost to record the draw | microseconds — ~10³× cheaper than creation | … |
| Triangle GPU time vs. Lab 0.3/0.4 clear floor | indistinguishable from the clear pass — three vertices are nothing | … |
| Winding postmortem symptom | blank frame, zero errors — the silent one | … |
Profiling & performance
Xcode GPU capture on the Metal triangle: find the render pass, the two shader functions, and the draw’s GPU timing; open the shader profiler on the fragment function just to see per-line costs exist (they’ll matter from Module 3 on). On the Vulkan side, keep the Tracy zones from Task 5. If the Linux desktop (RTX 4090) is handy, run vk_triangle there and take a first RenderDoc capture — the mesh viewer showing your three vertices post-transform is the debugging view you’ll live in later, and it’s how the winding postmortem is diagnosed like a professional rather than by staring.
Analysis & reconciliation
Reconcile the creation-vs-draw asymmetry against the Background’s claim: what actually happened during those milliseconds (shader compile to GPU ISA, state validation), and why front-loading it is the design premise of both APIs. Explain the winding postmortem from first principles — where in the pipeline culling happens, what “front-facing” means after the viewport transform — and reconcile the negative-viewport experiment with it. Close notes.md with the state-location table: for each piece of state (winding, cull, viewport, blend, formats), where Vulkan put it vs. where Metal put it, and which choice you’d make if you were the API designer.
Going further
- Pipeline caches: create a
VkPipelineCache, serialize it to disk, and time second-run creation; read up onMTLBinaryArchiveas Metal’s equivalent. Load-time hitching is a real shipping problem and this is its standard mitigation. - Add a second pipeline with
polygonMode = LINE(Vulkan) /.linestriangle fill mode on the encoder (Metal) and a key to toggle — wireframe is a debugging view worth having wired in early. - Read the SPIR-V disassembly of your vertex shader (
spirv-dis) and find the interface variables the mismatch postmortem broke — the compiled artifact the validation layer was actually checking.