Imported from mattquinn56/Vulkan-Engine (
AGENTS.md). Install upstream withnpx skills add mattquinn56/Vulkan-Engine. Copyright stays with the author.
Local Agent Notes
General
- Prefer an existing library over writing the logic yourself. Search first —
vcpkg and
third_party/already cover a lot — and only hand-roll when no reasonable option exists, or when the dependency costs more than it saves. Say which you did and why. - Use US spelling everywhere: code, comments, commit messages, docs.
Version control
- Commits carry the repository owner's name only. Do not add
Co-Authored-Bytrailers, agent attribution, or generated-with notices to commit messages. - Never push. Commit locally and leave publishing to the repository owner.
- Stage files by name, only the ones your change touched. Never
git add -A,git add -u, orgit add .. Anything already modified in the working tree when you arrived was left that way deliberately, and sweeping it into a commit publishes a decision that was not yours to make. Checkgit statusbefore staging and leave what you did not touch alone. - One self-contained change per commit, so a bad one can be reverted in isolation. Build and verify before each commit, not just at the end.
- Commit messages are a subject line. Default to no body at all.
- Write a body only if, without it, a reader would revert the change believing it was a mistake. Wanting to share what you learned is not that reason.
- Same test as comments: if the body's job is to contrast the new code with the old, drop it. The diff already shows both sides.
Build
- This is a Visual Studio multi-config CMake build. Build Debug from the repository root with:
cmake --build build --config Debug --target Shaders engine - Use the CMake that generated the build tree:
C:\Program Files\CMake\bin\cmake.exe. A barecmakemay resolve to the Anaconda copy onPATH, which fails compiler-id detection against this tree. - Do not run that command while Visual Studio is already building or debugging the engine. A running session can lock SDL's
SDL2d.pdband make the command fail withLNK1201. - Shader source changes require rebuilding the
Shaderstarget. Adding or removing a shader also requires rerunning CMake configure/generate, because the rootCMakeLists.txtuses a configure-time glob; a deleted shader otherwise fails the build withMSB8066from a stale rule. - The Debug executable and runtime DLLs are written to
bin/Debug/, not underbuild/.
Source layout
Hardware ray tracing is the only render path; there is no rasterizer.
src/ is the single include root, so headers are included by their subfolder
path: #include "core/rt_engine.h".
core/—RtEngineitself.rt_engineis the class declaration plus the init/cleanup/run shell;device_context(instance, device, swapchain, render targets),frame_sync(command pools, fences,immediate_submit) andframe_draw(per-frame orchestration) implement members of that same class, so these files split the implementation, not the coupling.gpu_types.hholds the shared GPU-facing structs.passes/—ray_tracing_pipeline(acceleration structures, RT pipeline, shader binding table), plusaccumulation_pass,postprocess_passandvolumetrics.ray_tracing_pipeline.cppis over the ~500-line guideline on purpose: splitting it would cut across the BLAS/TLAS/SBT build sequence.scene/—gltf_import,scene_graphdraw-list building,camera, and generatedmeshesdata.gpu/— Vulkan plumbing shared by everything:gpu_resources,descriptor_alloc,descriptor_setup,image_utils,vk_init,shader_module.platform/— host-side concerns:resource_path,screenshot,ui_overlay.
src/CMakeLists.txt uses GLOB_RECURSE, so new files in any subfolder are
picked up by a reconfigure.
C++ style
- Format
src/*.cppandsrc/*.hwith the repository.clang-formatfile before committing C++ changes. src/scene/meshes.cppcontains generated mesh tables and is excluded via.clang-format-ignore.- Use four spaces and LF line endings. Braces are attached for anything executable — functions,
if/for/while, lambdas — and go on their own line for type definitions (struct,class,enum,union), so a type declaration stays visually distinct from code. Brace initialization is unaffected. .editorconfigdefines the whitespace and line-ending defaults for supported editors.- Use
PascalCasefor types,snake_casefor functions,camelCasefor locals and data-struct fields, and_camelCasefor class data members. Boolean names should describe a state or capability.
GUIDE.md
Do not document anything about GUIDE.md here. Its conventions live in
GUIDE.md itself — read that file's header before editing it.
Settings UI
- Keep commonly useful, visually interesting controls on the Essentials page. Put diagnostic or expert tuning controls under Advanced.
- Do not expose inert controls. If a future control provides useful roadmap
context, suffix its label with
(planned), render it disabled, and add one short explanation. Otherwise, leave it out until the feature works.
Comments
- Keep comments short. One or two lines is the norm. Reserve three or more lines for things that genuinely need it, such as a non-obvious invariant, a packed GPU layout, or a docstring on a key function.
- Comment the reason, not the mechanics. Do not restate what the next line does, and do not narrate a function step by step.
- A comment may not name anything that is not in the codebase. No previous implementation, no library you rejected, no approach you considered and dropped. Test: point at every thing your comment names. If a reader cannot find it in the tree, delete that clause. "PCG hash" passes; "PCG hash, better than the sin-based one" does not, because there is no sin-based one to look at.
- Explaining why the current code is shaped this way is fine and often useful. Explaining why you changed it is not — that is what the commit is for.
- Prefer no comment over a filler one. Deleting a redundant comment is an improvement.
Running and validation output
-
The working directory no longer matters: shaders and assets are resolved relative to the executable's own location, so
engine.execan be launched from anywhere. -
Validation layers are enabled by
bUseValidationLayersinsrc/rt_engine.h: on for Debug, off for Release. Validation work must therefore be done against a Debug build. -
vk-bootstrap's default validation callback writes validation messages to stdout in this project. Capture both streams anyway.
-
A verified PowerShell capture recipe from the repository root is:
$runDir = (Resolve-Path .\bin\Debug).Path $process = Start-Process ` -FilePath (Join-Path $runDir engine.exe) ` -WorkingDirectory $runDir ` -RedirectStandardOutput "$env:TEMP\vulkan-engine-stdout.log" ` -RedirectStandardError "$env:TEMP\vulkan-engine-stderr.log" ` -PassThruLet it render several frames, then close the window normally when cleanup behavior matters. For a bounded observation, stop only the returned process ID with
Stop-Process -Id $process.Id. -
Stop-Process -Forcekills the process before stdio is flushed, leaving both logs empty. To get output, send a normal close (taskkill /PID <id>) and then wait on the process, e.g.$process.WaitForExit(20000). -
Per-frame validation errors can make the log very large. Inventory distinct errors with:
Select-String "$env:TEMP\vulkan-engine-stdout.log" -Pattern 'VUID-[A-Za-z0-9-]+' -AllMatches | ForEach-Object { $_.Matches.Value } | Group-Object | Sort-Object Count -Descending
Current observed baseline
As of 2026-07-26, a Debug run on an NVIDIA GeForce RTX 4080 SUPER loads
assets/livingroom_vkr.glb with 11 lights, produces no validation messages,
and exits with code 0.
That is the bar to hold. Any validation output is a regression from the change under test. Re-check after every change, and deduplicate before concluding, because later errors can be masked by earlier invalid state.
A descriptor set that names only render targets should be written once, when
those targets are created, not per frame. Writing per frame touches a set the
previous frame still has pending, which is VUID-vkUpdateDescriptorSets-None-03047.
Use tools/summarize-validation.ps1 to count validation message identifiers
without double-counting the VUID repeated in each message's specification URL.
For deterministic diagnostic runs, engine.exe accepts --tonemap=on|off and
--debug-view=<n>. --help lists every option with its accepted range; CLI11
parses them, so --flag value works as well as --flag=value. Invalid options
exit with status 2 before Vulkan starts, and --help exits 0.
--render-path no longer exists: hardware ray tracing is the only render path.
--orbit=<deg> yaws the camera that many degrees per rendered frame, and
--orbit-frames=<n> stops it after n frames. Nothing else in the engine moves
the camera without a mouse, so these are the only way to exercise reprojection
in a capture run. Because the yaw is per frame rather than per second, a capture
at frame N is identical between runs.
Pair them to measure temporal filtering against ground truth: render the panned
pose with --orbit=X --orbit-frames=N --frames=N, then render the same pose
left to converge with --frames=N+480, and diff the two.
Verifying a frame
Prefer this over launching interactively — it needs no manual interaction and gives you an image to inspect:
.in\Debug\engine.exe --screenshot=C:\path o\shot.png --frames=45
It renders --frames frames, writes the presented swapchain image as a PNG, and
exits 0 on its own. Raise --frames when the Monte Carlo accumulation needs
longer to converge; the image is visibly noisy at low frame counts.
A capture run creates its window hidden and does not grab the mouse, so nothing appears on screen and it can run while you work. It is not surface-less rendering: a window and swapchain still exist, they are just never shown.
Golden image tests
ctest --test-dir build -C Debug --output-on-failure
Run after any change that could affect the rendered image, and treat a failure
as a regression until proven otherwise. On failure the run writes .actual.png
and .diff.png into build/golden-output/; look at the diff before assuming
the reference is stale.
Regenerate references only for an intentional output change:
cmake --build build --config Debug --target golden-update.
Comparison lives in the standalone imagediff target rather than in the engine,
so it can be run against any two PNGs and exercised on its own. A pixel counts
as differing when any channel is more than 4 apart, and up to 0.2% of pixels may
differ before a test fails; back-to-back runs on one GPU agree to within a
single channel value, so that slack is for driver and hardware variation.
Golden cases must pass --no-ui. The overlay prints a frame time that changes
every run, so a reference including it would fail against itself. Cases are
declared in the root CMakeLists.txt.
They must also pass --resolution. Without it the window is sized from the
desktop, so the rendered extent — and therefore the image — depends on the
display of whichever machine generated the reference. GOLDEN_RESOLUTION in the
root CMakeLists.txt pins every case to 1250x800.
Timings vary enormously with what else is using the GPU. A run that normally
takes 6 s can take 150 s with another application holding the device, which looks
exactly like a hang. Check nvidia-smi before concluding the engine stalled.
Results need an idle GPU too, not just timings. With another application (a
game, for example) saturating the device, the presented output of runs with
different frame counts can differ by one or two 8-bit codes on dark pixels —
enough to fail the strict stability.* thresholds while the renderer itself is
still bit-deterministic. Verified 2026-08-18: the same binary passes on an idle
GPU and fails under load, on both sides of an unrelated change. Rerun on an
idle GPU before treating such a failure as a regression.
livingroom renders 60 frames, livingroom_converged renders 480. The long case
exists to catch changes in how fast the image converges, not just what it
converges to; a change that only slows accumulation passes the short case and
fails the long one. livingroom_panning is the only case with a moving camera.
The stability.* cases enforce the deterministic-baseline invariant: the
real-time frame is a pure function of the camera pose, so identical poses —
still, settled after motion, or one orbit revolution apart — must render
bit-identical images. Back-to-back runs are bit-identical on one GPU, so a
non-trivial diff is a real change.
Phase 2 verification is recorded in docs/validation-phase2.md. Its raw logs are
under ignored out/validation-phase2/.