Lab 0.2 — Modern C++20 for Engine Code
← Course 4 syllabus · Module 0 · Prev: « Lab 0.1 · Next: Lab 0.3 »
Goal
Establish the C++20 working subset this course’s engine is written in — not a tour of the language, but the specific idioms GPU-facing code lives on: RAII for API handles, move semantics for resource ownership, std::span and views over copies, constexpr for compile-time math, concepts for readable templates, and the layout/aliasing discipline carried over from Course 3 Part IV. The deliverable is real: the engine/core math and utility components that Modules 2–7 build on, each choice benchmarked or proven in disassembly rather than asserted.
Recommended reading
- Course 3, Modules 7–8 (syllabus) — the modern-C discipline (fixed-width types, layout, aliasing, UB) that this lab extends rather than replaces; everything there about
memcpy-as-type-pun and strict aliasing still binds in C++. - Lengyel — the vectors, matrices, and transforms chapters (title-level reference), read here only for conventions — storage order, handedness, notation. The mathematics itself is Course 1 Section 1, mastered.
- D&P — the matrices and coordinate-space chapters as a gentler second telling of the same conventions.
- The GLM manual’s sections on matrix storage (column-major),
GLM_FORCE_DEPTH_ZERO_TO_ONE, andGLM_FORCE_RADIANS— the three switches whose defaults differ from what Vulkan/Metal need.
Prerequisites
- Lab 0.1: the skeleton builds under all three presets; Tracy and Google Benchmark run.
Project & environment setup
Work happens in engine/core/ (targets engine_core) plus a benchmark target per task family. No new dependencies; GLM is already fetched. Sanitizer preset from Lab 0.1 stays on for every debug run in this lab.
Where results go:
| Artifact | Path |
|---|---|
| Notes, predicted-vs-measured, disassembly excerpts | labs/lab-0-2/notes.md |
| Benchmark JSON (handle table, math types, span vs copy) | labs/lab-0-2/benchmarks/ |
Background
The subset, and why each piece earns its place in an engine:
- RAII and the rule of zero/five. Every GPU API in this course hands back opaque handles (
VkBuffer,MTL::Buffer*,cudaStream_t) whose lifetime must outlive GPU work in flight. The C++ answer is ownership types: a move-only wrapper whose destructor releases, copy deleted, move transferring. Get this pattern right once here, on a fake handle type, before Vulkan makes mistakes expensive. - Handles vs. pointers. Engines increasingly avoid raw object graphs in favor of index handles into pooled storage (a 32-bit index + generation counter): cache-friendly, trivially serializable, dangling-safe. This lab builds that pool; Module 4’s resource system is this class with a GPU attached.
std::span,std::string_view, ranges. Non-owning views make “a function that takes some vertices” not allocate. The benchmark task shows what the copies you didn’t take were worth.constexprand concepts. Compile-time projection matrices and unit tests that run in the compiler; concepts (std::floating_point, a hand-rolledvertex_attributeconcept) replacing SFINAE noise in the few templates the engine needs.- What stays out: exceptions on the frame path (error codes/
std::expected-style returns instead), RTTI,shared_ptrin hot code, iostreams in the engine core. Each exclusion gets one sentence of justification in your notes — “because a book said so” doesn’t count.
Tasks
- Math conventions header. Create
engine/core/math.hpppinning GLM configuration (GLM_FORCE_RADIANS,GLM_FORCE_DEPTH_ZERO_TO_ONE, explicit column-major storage note) and aliases (float3,float4x4, …). Addconstexprbuilders for translation/rotation/scale and a perspective projection targeting 0-to-1 depth — with astatic_assertunit test evaluating one known matrix at compile time. - A move-only handle wrapper. Write
unique_handle<T, Deleter>(or equivalent) with deleted copy, defaulted move, and a release/reset API; exercise it on a fakeFakeGpuObjectwhose create/destroy counts are asserted in a test. Prove in the debugger that a moved-from wrapper destroys nothing. - A generational pool. Implement
pool<T>returning{index, generation}handles:create/destroy/get, with stale-handle detection. Benchmark iteration over the pool vs. iteration over astd::vector<std::unique_ptr<T>>of the same size — this is the data-oriented-design argument in one number. - Span discipline. Write a
mesh_stats(std::span<const float3>)-style function family; benchmark span-passing vs. by-valuestd::vectorcopies across sizes (1 K → 1 M vertices). - Read the disassembly. For the
constexprprojection builder and the pool iteration loop, capture-O2disassembly excerpts and annotate: what got folded, what got vectorized (NEON — Course 3 Module 5 eyes), what didn’t and why. - Error-handling policy. Write the engine’s
result/error type (or adopt one) and document, in one page indocs/, the frame-path rule: what may fail, how it reports, what asserts instead.
Deliverable & expected results
engine_corecontainingmath.hpp, the handle wrapper, the pool, and the error type, all under test; benchmarks recorded.notes.mdcarrying the predicted-vs-measured table and two annotated disassembly excerpts.
| Quantity | Predicted | Measured |
|---|---|---|
Pool iteration vs. vector<unique_ptr> iteration (1 M elements) |
pool faster by an integer factor — contiguous vs. pointer-chasing (Course 3 M2’s cache ladder predicts it) | … |
Span pass vs. vector copy (1 M float3) |
copy cost ∝ 12 MB memcpy; span ~free | … |
constexpr projection builder, runtime cost at -O2 |
zero — folded to stored constants | … |
| Moved-from wrapper double-destroy | never — destroy count exactly 1 per object | … |
Profiling & performance
Google Benchmark is the instrument here: repetitions pinned, medians compared, results archived to benchmarks/. One Tracy capture of the pool benchmark under the profile preset makes a nice cross-check that zones and benchmark timings agree on the same code.
Analysis & reconciliation
Reconcile the pool-vs-pointers factor against the cache model from Course 3’s ladder: given the element size and the M-series cache line, what factor should contiguity buy, and did it? Where the span benchmark shows less advantage than predicted, look for the allocator’s small-size regime and say so. Close with the paragraph that matters: which of these idioms are now defaults for the rest of the course, and what evidence backs each.
Going further
- Add a
std::pmrarena to the pool benchmark and measure allocation-heavy churn (create/destroy storms) against the default allocator. - Try
[[no_unique_address]]on the deleter inunique_handleand verify the size instatic_assert. - Port one benchmark to the Linux desktop (RTX 4090) and compare the contiguity factor across the two memory systems — foreshadowing Module 1.