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.
Recommended reading
- Vulkan Tutorial — the “Vertex buffers” sequence: vertex input description, buffer creation, staging buffer, index buffer. Then read the VMA README’s “Quick start” and usage-pattern sections — the tutorial teaches raw
vkAllocateMemoryso you understand what VMA is doing; the course code uses VMA from here on. - vkguide.dev — the buffer and mesh sections of the new chapters, which use VMA idiomatically and show the engine-shaped version of this lab’s code.
- MbT — the chapters introducing vertex descriptors, buffers, and
setVertexBytesvs. buffer binding (title-level; 5th-ed. numbering — confirm against the copy in hand). - C&S — the resource-management chapter, topic-level: how a real engine wraps allocations, and where VMA sits in that design. Skim now; Module 4 implements it.
- Apple, Metal documentation — “Choosing a Resource Storage Mode for Apple GPUs”; short and definitive on why shared storage is the right default on unified memory.
Prerequisites
Project & environment setup
- Vulkan:
engine/vulkan/gains an allocator wrapper (VMA instance created after the device, destroyed before it) and aBufferRAII type in the Lab 0.2 style; lab executablelabs/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, colorfloat3, uvfloat2— interleaved, offsets 0 / 12 / 24, stride 32 bytes. Declare it once as the vertex input description (Vulkan) andMTLVertexDescriptor(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)
- 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 (
vmaBuildStatsStringor the properties query) as a table innotes.md: heap sizes, which types carryDEVICE_LOCAL,HOST_VISIBLE, both. On the Mac (MoltenVK) note what a unified machine reports. - Indexed quad. Four vertices, six indices, host-visible first — get vertex input description, binding, and
vkCmdDrawIndexedcorrect with the least memory ceremony. Checkerboard the colors so interpolation proves the layout is right. - 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 innotes.mdbefore 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 orSHARING_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.) - 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 againstmaxPushConstantsSize. - 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)
- Same quad, same grid.
MTLBufferinstorageModeShared,MTLVertexDescriptormirroring the 32-byte layout, indexed draw viadrawIndexedPrimitives. No staging, no copy — record how many of the Vulkan task’s steps simply do not exist. setVertexBytesanimation. The same animated transform, passed withsetVertexBytes— then once via a persistentMTLBufferinstead, and note innotes.mdwhen Apple’s guidance says each is appropriate (the ~4 KB rule).- Match check. Same grid parameters, same animation, two windows side by side; screenshot to
captures/.
Deliverable & expected results
vk_meshandMetalMeshrendering the identical animated grid; the memory-type table, ownership answer, and staging-vs-shared comparison innotes.md; benchmark tables inbenchmarks/.
| 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_AUTOwith theHOST_ACCESS_SEQUENTIAL_WRITEflag 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_LOCALheap with its own size is the whole staging argument in one table.