GPU Compute Harness
Vulkan Compute Lab
A C++20 harness for writing GPU compute shaders and seeing them immediately. There is no graphics pipeline anywhere in the project, compute writes an offscreen storage image that is blitted straight to the swapchain, and GLSL is compiled to SPIR-V at runtime, so saving a .comp file swaps the pipeline live. Five shaders ride on it, including a 3D N-body galaxy you can fly into, a progressive path tracer, and a 32,768-agent flock.

Employer signal
What This Project Shows
This is the project where I work directly against an explicit low-level API with no engine underneath it: device and queue selection, descriptor sets, image layout transitions, and multi-frame synchronisation are all mine to get right. It is also the clearest evidence of how I debug. Every hard bug in it was found by deriving the quantity that was wrong (an interpolation invariant, a floating-point conditioning bound, a rotation curve, a conserved angular momentum, an IEEE corner case) rather than by tuning constants until the picture looked right.
Problem
What Needed To Be Solved
The standard route into Vulkan works through render passes, framebuffers, vertex buffers, and a graphics pipeline before a single pixel appears, and none of that is needed to run a compute shader. I wanted the shortest honest path from 'the GPU executes my code' to 'I can see it', and then for the shader, not the C++, to be the thing I iterate on.
Approach
How I Built The Solution
Cutting the graphics pipeline is what gets a dispatch on screen; the rest of the project is about what that buys. Shaders are compiled by glslc at runtime rather than at build time, which is what makes live reload possible, and from there the harness became general by letting the shader header declare what kind of thing it is: a plain pixel shader, an `//!nbody` particle simulation that expands into four passes, or an `//!accumulate` progressive renderer that keeps a persistent float image across frames. The C++ never learns what any individual shader is doing: it reads the header, sizes the buffers behind it, and dispatches. That is also the constraint the rest of the design answers to: the file being edited at runtime is the file that decides what the host does, so anything the header invites you to change has to stay correct when you change it.
Outcome
What It Demonstrates
The three simulation shaders were measured on the machine it was built on: an RTX 2080 SUPER at 1280x720, where the galaxy holds about 79 fps at 65,536 bodies and about 21 at 131,072, and the path tracer converges at roughly 310 samples per second. The flock is where the neighbour cull shows up as a number: 4x the pair interactions costs about 2.3x the frame time (1.1e9 pairs per step at about 95 fps, 4.3e9 at about 42) because only around 3.5% of pairs fall inside even the widest rule radius. Verification is Vulkan validation layers enabled in Debug builds plus that headless capture path, which is the designed mechanism rather than a leftover: the renderer gets checked by measuring its own output instead of by looking at a window. What it does not have is a test suite: there is no test directory and no CI, and four static_asserts on the push-constant layout are the only compile-time checks in the project. Every figure above is a frame rate rather than a GPU dispatch time, because timestamp queries are on the list and not implemented; gravity is exact all-pairs with no tree, so this is deliberately a tracer galaxy and getting to millions of bodies needs Barnes-Hut or a particle-mesh solver; and both entry points are PowerShell against the Windows SDK layout, so it is Windows-only in practice: nothing in the repo indicates a Linux or macOS run, and portability is untested. What generalises is the habit: absolute input values are safe to read and dangerous to re-anchor, an invariant that must hold throughout an animation should be interpolated in the space where it is affine, and a rendering artifact is usually a numerics bug in costume.
Evidence From Source
A UI bug that was really a question about interpolation
Zooming lurched toward the cursor mid-gesture. The cause was that pan and log-zoom were eased independently, so 'keep the point under the cursor fixed' held at the two endpoints of a scroll and nowhere in between. The fix is not a damping constant, it is a change of state representation: store the view as (centre, scale = 1/zoom) and ease both with the same factor. The anchor condition centre == anchor - uv * scale is then affine in scale, so if both ends of a linear interpolation satisfy it, every intermediate frame satisfies it identically. Taking the anchor from the displayed view rather than the target view is what puts the start of the ease on that line. The invariant is written on the Navigation struct so the next person to touch the smoothing knows what they are allowed to break. An earlier bug in the same input path had the same kind of answer: the mouse uniform was fed the window centre until a button went down and the cursor position afterwards, so pressing the mouse teleported the image. Separating the two quantities (mouse is the raw pointer, read and never re-anchored, while centre and zoom accumulate from cursor deltas) makes a press contribute exactly zero by construction rather than by a guard.
A limit derived rather than picked
The shader computes p = centre + uv * scale, so adjacent pixels differ by scale/height; once that step falls below the float32 ULP of the coordinate itself (|p| * 2^-23), a whole neighbourhood collapses onto one representable value and the view can only move in lattice steps, it snaps rather than slides. The zoom ceiling therefore comes out of the ULP relation, height * 0.3 * 2^-23, about 38,800x at 720p: rather than out of a number that looked safe, and the commit that added the cap records the regimes the relation predicts: clean at 30,000x, visibly stepped at 100,000x, coarse mush at 300,000x. The 0.3 is an explicit assumption about how far the shipped shaders' coordinates stray from the origin, which is why --max-zoom overrides it and --max-zoom -1 removes it. Going deeper is a precision problem and not a tunable: it needs fp64 coordinates or perturbation theory against a reference orbit, and neither is implemented.
Rendering artifacts diagnosed as physics
Making the galaxy 3D reintroduced an exploding core. Seed velocities balanced on the spherical radius when centripetal balance for an in-plane orbit is set by the cylindrical one: a particle at R = 0.002 sitting 0.018 above the plane was handed v_c = 0.31 when it needed 0.012, so the core launched itself outward as an expanding ring. The 2D version could not have had that bug: with no disk thickness the two radii were the same number. It was localised numerically rather than by eye: the app renders and screenshots itself headlessly, with --center and --zoom for reproducible framing, so an expanding ring is a radial brightness profile whose peak marches outward frame over frame rather than a picture that looks wrong. An earlier version of the same class had seeded from ideal sqrt(GM/r) while the integrator applied Plummer-softened gravity, which inside the softening radius is far weaker, and a version before that fragmented into bound clumps because Toomre Q evaluated to about 0.26, well under the Q >~ 1 stability threshold. In each case the picture looked like a shader artifact and the fault was in the dynamics.
A flock that was secretly a galaxy
The first flocking draft was not flocking at all. It was a flat rotating disk with a hollow core, visually indistinguishable from the galaxy shader running beside it. The seed gave every agent a shared tangential velocity about +z, which hands the system global angular momentum at frame 0, and none of the three boids rules can dissipate it: alignment matches headings, so it preserves net rotation rather than damping it, and the flock centrifuges into a mill and stays there forever. The fix is in the seeding rather than the rules: headings are now locally coherent, shared within a coarse 0.075 spatial cell so the flock starts organised into patches instead of as a gas of disagreeing directions, but globally isotropic, so the system carries no net rotation. The shared-swirl term survives at zero rather than being deleted, because the mill is a real attractor of the model and worth being able to reach on purpose.
Bugs caught by reading, before anyone saw them
Three defects in the path tracer and accumulator were found by argument rather than by looking at output. The tracer clamped fireflies before rejecting non-finite samples, and since GLSL leaves min() undefined for a NaN operand while both NVIDIA and AMD return the non-NaN one, min(NaN, clamp) laundered a NaN into a bright finite sample banked permanently into the accumulator. The per-pixel RNG seed XOR-ed three products, which is not injective, and because the sample-index term XORs in a value common to every pixel, a collision recurs identically every frame: two pixels sharing a stream forever is correlated noise that averaging can never remove, measured at 144 permanently aliased pixels on a 1280x720 grid. And the accumulator discarded its sum on every swapchain recreation, but VK_SUBOPTIMAL_KHR alone triggers a recreate and some drivers report it steadily at an unchanged extent, which would have pinned the path tracer at one sample while merely looking noisy.