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.

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.
  • constexpr and concepts. Compile-time projection matrices and unit tests that run in the compiler; concepts (std::floating_point, a hand-rolled vertex_attribute concept) 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_ptr in 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

  1. Math conventions header. Create engine/core/math.hpp pinning GLM configuration (GLM_FORCE_RADIANS, GLM_FORCE_DEPTH_ZERO_TO_ONE, explicit column-major storage note) and aliases (float3, float4x4, …). Add constexpr builders for translation/rotation/scale and a perspective projection targeting 0-to-1 depth — with a static_assert unit test evaluating one known matrix at compile time.
  2. 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 fake FakeGpuObject whose create/destroy counts are asserted in a test. Prove in the debugger that a moved-from wrapper destroys nothing.
  3. 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 a std::vector<std::unique_ptr<T>> of the same size — this is the data-oriented-design argument in one number.
  4. Span discipline. Write a mesh_stats(std::span<const float3>)-style function family; benchmark span-passing vs. by-value std::vector copies across sizes (1 K → 1 M vertices).
  5. Read the disassembly. For the constexpr projection builder and the pool iteration loop, capture -O2 disassembly excerpts and annotate: what got folded, what got vectorized (NEON — Course 3 Module 5 eyes), what didn’t and why.
  6. Error-handling policy. Write the engine’s result/error type (or adopt one) and document, in one page in docs/, the frame-path rule: what may fail, how it reports, what asserts instead.

Deliverable & expected results

  • engine_core containing math.hpp, the handle wrapper, the pool, and the error type, all under test; benchmarks recorded.
  • notes.md carrying 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::pmr arena to the pool benchmark and measure allocation-heavy churn (create/destroy storms) against the default allocator.
  • Try [[no_unique_address]] on the deleter in unique_handle and verify the size in static_assert.
  • Port one benchmark to the Linux desktop (RTX 4090) and compare the contiguity factor across the two memory systems — foreshadowing Module 1.