Lab 2.2 — Buffers, Vertex Input & Staging Uploads

Course 4 syllabus · Module 2 · Prev: « Lab 2.1 · Next: Lab 2.3 »

Goal

Feed the pipeline real data: vertex and index buffers, and with them the topic that separates graphics-API users from graphics engineers — GPU memory. Where Lab 2.1 hardcoded three vertices in the shader, this lab builds a procedurally generated grid mesh and moves it into GPU memory the way the two APIs each want it done: Vulkan’s explicit memory types and heaps (adopting VMA so the explicitness stays teachable rather than torturous), the staging-buffer upload as the portable discipline for device-local memory, and Metal on Apple Silicon where unified memory makes staging unnecessary and storageModeShared is simply correct. Along the way: indexed drawing and why vertex reuse matters, push constants vs. Metal’s setVertexBytes for small per-draw data, and a first layout experiment — interleaved vs. separate attribute streams — measured, not asserted.

Prerequisites

  • Lab 2.1 — pipelines on both APIs, shader build integration working.
  • Lab 0.3’s frame loop and validation-clean rule; VMA already declared in Lab 0.1’s dependency block (FetchContent) — this lab is where it first links.

Project & environment setup

  • Vulkan: engine/vulkan/ gains an allocator wrapper (VMA instance created after the device, destroyed before it) and a Buffer RAII type in the Lab 0.2 style; lab executable labs/lab-2-2/vk_mesh.
  • Metal: metal-swift/MetalMesh/ extending the Lab 2.1 app.
  • The grid generator is shared CPU code — put it in engine/core/ so both tracks consume identical vertex data (the Swift app can regenerate it from the same parameters; identical output is part of the deliverable).
  • Vertex layout for this lab (given as a spec, not code): position float3, color float3, uv float2 — interleaved, offsets 0 / 12 / 24, stride 32 bytes. Declare it once as the vertex input description (Vulkan) and MTLVertexDescriptor (Metal).

Where results go:

Artifact Path
Notes, memory-type table, ownership answer, reconciliation labs/lab-2-2/notes.md
Screenshots labs/lab-2-2/captures/
Interleaved-vs-SoA and upload timings (Tracy exports, tables) labs/lab-2-2/benchmarks/

Background

Memory types and heaps. Vulkan exposes the machine’s memory as heaps (physical pools with sizes) and types (heap + property flags). The two flags that matter now: DEVICE_LOCAL (fast for the GPU) and HOST_VISIBLE (mappable by the CPU). On a discrete GPU these are largely disjoint — hence the staging pattern: write vertex data into a host-visible buffer, record a vkCmdCopyBuffer into a device-local buffer, submit, wait or fence. And this course owns the machine that motivates it: the Linux desktop’s RTX 4090 is a discrete part with its own GDDR6X VRAM across PCIe, so DEVICE_LOCAL and HOST_VISIBLE name genuinely different physical memories there and the staged copy is a real transfer, not a formality. On unified-memory machines — Apple Silicon by design — both flags appear on the same heap, and the same code degenerates gracefully. The staging pattern is the portable discipline precisely because it serves both: correct everywhere, and the fast path where the memories are truly separate. VMA’s job is to pick the right type from a usage declaration and to sub-allocate, replacing a hundred lines of flag-matching folklore.

Metal’s version: MTLBuffer with a storage mode. On Apple Silicon, storageModeShared gives CPU and GPU the same bytes — allocation is upload. storageModePrivate plus a blit exists (and matters for textures and discrete-era code), but for this lab’s mesh data, shared is idiomatic and Apple’s documentation says so plainly. The contrast — one API’s ceremony vs. the other’s shrug, for the same physical memory arrangement — is the lab’s written punchline.

Indexed drawing. An \(n \times n\)-cell grid has \((n+1)^2\) unique vertices, but drawing it as independent triangles touches \(6n^2\) vertices (two triangles per cell, three each). An index buffer stores the \(6n^2\) references as 2- or 4-byte indices into the \((n+1)^2\)-vertex buffer: with 32-byte vertices and 4-byte indices, non-indexed costs \(6n^2 \cdot 32\) bytes of vertex data, indexed costs \((n+1)^2 \cdot 32 + 6n^2 \cdot 4\). Average reuse per vertex is \(6n^2/(n+1)^2 \to 6\) — and reuse is not only memory: the post-transform vertex cache means a reused index may skip the vertex shader entirely. Derive your own numbers for your chosen \(n\) before measuring.

Small per-draw data. Both APIs have a fast path for a handful of bytes per draw, bypassing buffer machinery: Vulkan push constants (at least 128 bytes guaranteed — query the limit) written into the command buffer itself, and Metal’s setVertexBytes/setFragmentBytes (Apple recommends it for anything under ~4 KB). This lab uses them for an animated transform — a preview of Lab 2.3, where bulk per-frame data motivates uniform buffers proper.

Tasks

Vulkan (C++20)

  1. VMA adoption. Create the allocator; rebuild Lab 2.1 unchanged on top of it (no visible difference — that’s the test). Print and record VMA’s view of the heaps/types (vmaBuildStatsString or the properties query) as a table in notes.md: heap sizes, which types carry DEVICE_LOCAL, HOST_VISIBLE, both. On the Mac (MoltenVK) note what a unified machine reports.
  2. Indexed quad. Four vertices, six indices, host-visible first — get vertex input description, binding, and vkCmdDrawIndexed correct with the least memory ceremony. Checkerboard the colors so interpolation proves the layout is right.
  3. The grid. Generate the \(n \times n\) grid (parameterized; \(n = 64\) is a good default), upload via the staging pattern: staging buffer (host-visible) → vkCmdCopyBuffer → device-local vertex and index buffers. Answer in notes.md before coding: which queue executes the copy, and what has to happen if it’s a dedicated transfer queue rather than the graphics queue? (Queue-family ownership transfer or SHARING_MODE_CONCURRENT — name the tradeoff, then take the simple road: the graphics queue is fine here, and knowing why it’s fine is the point.)
  4. Push-constant animation. A small struct (a 2-D offset/scale or a full mat4) pushed per draw, animating the grid; declare the range in the pipeline layout, check it against maxPushConstantsSize.
  5. Interleaved vs. separate streams. Build the same grid as two vertex bindings — positions in one buffer, color+uv in another — alongside the interleaved version, and benchmark both (same camera, same \(n\), GPU time via the HUD timestamps from Lab 0.3’s loop, plus Tracy CPU zones). At this size expect noise; scale \(n\) until the difference is signal or you can state it’s below measurement floor. Both results are worth having.

Metal (Swift)

  1. Same quad, same grid. MTLBuffer in storageModeShared, MTLVertexDescriptor mirroring the 32-byte layout, indexed draw via drawIndexedPrimitives. No staging, no copy — record how many of the Vulkan task’s steps simply do not exist.
  2. setVertexBytes animation. The same animated transform, passed with setVertexBytes — then once via a persistent MTLBuffer instead, and note in notes.md when Apple’s guidance says each is appropriate (the ~4 KB rule).
  3. Match check. Same grid parameters, same animation, two windows side by side; screenshot to captures/.

Deliverable & expected results

  • vk_mesh and MetalMesh rendering the identical animated grid; the memory-type table, ownership answer, and staging-vs-shared comparison in notes.md; benchmark tables in benchmarks/.
Quantity Predicted Measured
Indexed vs. non-indexed data for your \(n\) from the formula: \((n{+}1)^2\!\cdot\!32 + 6n^2\!\cdot\!4\) vs. \(6n^2\!\cdot\!32\) B — compute it
Average vertex reuse on the grid \(6n^2/(n+1)^2 \approx 6\)
Staging upload time for the grid small — µs-to-ms scale for kilobytes-to-megabytes; predict from your buffer size and a plausible copy bandwidth direction, not a made-up number
setVertexBytes vs. buffer bind for 64 B/draw no visible GPU difference at one draw — it’s a CPU-convenience/latency path
Interleaved vs. separate streams at large \(n\) small or below noise on these GPUs; interleaved wins if anything, since this shader reads every attribute

Profiling & performance

Tracy is this lab’s tool: zone the generator, the staging write (memcpy into the mapped pointer), the copy submission, and the wait — the upload’s anatomy as a timeline. Keep an upload zone name that Module 4’s streaming work will reuse. Capture one trace where the grid is regenerated per-frame (temporarily) to see upload cost inside a frame — the sin the staging architecture exists to manage — then put it back to load-time. On the Metal side, the HUD’s memory readout before/after allocation is enough for now.

Analysis & reconciliation

Reconcile the indexed-vs-non-indexed numbers against your derivation. Explain your interleaved-vs-SoA result in cache terms (Course 3’s mental model): a vertex fetch walks memory in layout order, and this lab’s shader touches all attributes — then state the case where SoA wins (a pass reading positions only; shadow passes will make this real in Module 5). Write the honest memory-architecture paragraph: what the staging pattern buys on the discrete 4090 (device-local placement — vertex fetches from VRAM instead of across PCIe), what it buys on the unified Mac (nothing but portability), what it costs (a copy and a sync), and why the engine keeps one code path for both. File the transfer-queue question’s full answer — ownership transfer — as something Lab 4.1’s resource system revisits.

Going further

  • Try VMA_MEMORY_USAGE_AUTO with the HOST_ACCESS_SEQUENTIAL_WRITE flag vs. an explicit staged copy on both machines, and read VMA’s docs on what it actually picked (vmaGetAllocationMemoryProperties).
  • 16-bit indices: your grid at \(n = 64\) fits in uint16 — measure the index-buffer size halve, and find the \(n\) where it stops fitting.
  • On the Linux desktop (RTX 4090), print the same VMA heap table and diff it against MoltenVK’s — a discrete part next to a unified one; the disjoint DEVICE_LOCAL heap with its own size is the whole staging argument in one table.