Imported from GlennSong/raytracer-clang (
src/renderer/vulkan/AGENTS.md). Install upstream withnpx skills add GlennSong/raytracer-clang --skill vulkan. Copyright stays with the author.
src/renderer/vulkan/ — Agent Guide
The Vulkan backend (Linux + Windows), the second implementation of the
Renderer seam (../AGENTS.md). Status: Phases 0–1 landed — device/
swapchain bring-up, the SPIR-V shader toolchain, and forward lit single-mesh
draws (vulkan_renderer.{h,cpp}, shaders/vulkan/mesh.{vert,frag}). Written
against the Vulkan 1.0 spec; unverified on device (no GPU/SDK in CI — needs a
real Linux/Windows run with the validation layers). Phases 2+ (textures, full
forward, shadows, IBL, post) still to do. Decision: ADR-0057. Plan:
docs/vulkan-renderer-plan.md. Parity reference: ../metal/AGENTS.md.
What exists after Phase 1
vulkan_renderer.h—VulkanRenderer : Renderer, pimpl (no Vulkan in header).vulkan_renderer.cpp— instance (+ debug messenger) → surface (Window seam) → device → swapchain → depth → render pass (color+depth) → framebuffers → command buffers → per-frame sync → descriptor set layout + per-frame global UBO + descriptor pool/sets → forward graphics pipeline →drawFrame. DefinesRenderer::create().- Rendering:
uploadMeshpacks the (double) engineVertexto floatGpuVertex, uploads device-local vertex/index buffers via a staging copy.setCamera/setLightsfill aGlobalsUBO(viewProjection with the clip-space Y-flip baked in bypackMat4, camera pos, sun, ambient).drawMeshqueues a draw with aMeshPushpush-constant (model + albedo/metallic + emission/ roughness).endFrameuploads the UBO and records the queued draws. - Shaders compile offline:
glslcin CMake turnsshaders/vulkan/*.{vert, frag}into.spvunderRT_VULKAN_SHADER_DIR(a compile-def path), loaded at pipeline creation.mesh.fragis real Cook-Torrance GGX (sun + ambient). - Stubs until later phases:
uploadTexturereturns a valid handle, no GPU image yet (Phase 2). No shadows/IBL/post/instancing/terrain. Back-face culling is off in Phase 1 (VK_CULL_MODE_NONE) until winding is confirmed on device — Phase 2 turns it on. No reverse-Z yet (standard [0,1] depth, LESS_OR_EQUAL). - Seam plumbing:
Window::createVulkanSurface+requiredVulkanInstanceExtensions(window.cpp, GLFW-sealed, gated byRT_HAVE_VULKAN);Renderer::setWindow(renderer.h) hands the window over beforeinitialize(native handle is null on Linux); CMake selects this backend whenfind_package(Vulkan)succeeds. - Editor viewport (hosted) surface: the same backend renders the Qt editor's
viewport.
HostedWindow(../hosted_window.h) implements the surface seam by delegating to a host-installed provider (setVulkanSurfaceProvider), so the hosted window stays toolkit-free and headless-testable. The Qt host (src/editor_app/editor_main.cpp) resolves the platform-native handles from the Qt plugin and callssrc/editor_app/vulkan_viewport.cpp, which owns the Vulkan/Win32/Xlib surface headers (kept out of any Qt TU — Xlib's macros collide with Qt). Win32 + Xlib paths exist; Wayland falls back (run withQT_QPA_PLATFORM=xcb). CMake buildseditor_appwith this backend on non-Apple when Vulkan is found, elsenull_renderer.cpp. Shared SPIR-V build: thert_build_vulkan_shaders()CMake function (onevulkan_shaderstarget for both the viewer and the editor).
Conventions decided in code (Phase 1)
- Matrices: engine
Mat4is row-major double,M*v;packMat4transposes to column-major float for GLSL (matching Metal'stoSimd) and, for the viewProjection, negates clip row 1 = the Vulkan Y-flip. Model matrices are packed without the flip (the VP carries it). - Depth:
perspective/orthographicalready target [0,1]; Phase 1 uses forward-Z (clear 1.0,LESS_OR_EQUAL). Reverse-Z (parity with Metal) is a later refinement. - Push constants for per-draw data (≤128 B); a per-frame UBO for globals; bigger arrays (lights, instances) become UBO/SSBO in later phases.
When code lands here, keep this guide in sync — it exists so future work doesn't re-derive the structure. Update it at the end of each phase.
Also update
docs/renderer-parity.mdwith every feature add/change: it is the Metal↔Vulkan parity ledger. New Vulkan code is🟡(unverified — no GPU in CI) until it's run on real hardware, then✅with a dated Verification-Log line. Anything Metal has that Vulkan doesn't goes in as❌/⚠️so the gap is tracked, not lost.
Target shape
| Concern | Choice (ADR-0057) |
|---|---|
| Platforms | Linux + Windows, one backend (both run Vulkan) |
| Selection | CMake non-Apple branch links this in place of null_renderer.cpp |
| Surface | Window::createVulkanSurface(VkInstance) (pimpl'd in window.cpp, no GLFW types leaked) — not by reaching through GLFW |
| Shaders | GLSL in shaders/vulkan/, compiled to SPIR-V offline by glslc in CMake; ship .spv. No runtime shader compiler (stays dep-free). Trade-off: no hot-reload (Metal has it). |
| GPU structs | reuse shaders/metal/shader_types.h for CPU↔GPU layout (std140/std430-aware); keep in lockstep |
Files (planned): vulkan_renderer.h (declares the Renderer subclass),
vulkan_renderer.cpp (implementation, built up over the phases).
Conventions — same as Metal except where Vulkan forces a difference
Match ../metal/AGENTS.md for the pass graph, the live-tuning fields, the
material texture-flag bitmask, and resource handles (SlotMap<…,Tag> per the
seam). Differences to absorb inside this backend so engine math stays shared:
- Clip space / depth. Vulkan is Y-down in NDC and depth is [0,1] (vs Metal's
Y-up, and the Metal backend's reverse-Z [1,0]). Absorb this at projection
upload + viewport (negative-height viewport for the Y-flip, or flip in the
projection). Keep reverse-Z for parity with Metal's depth precision
(clear to 0,
VK_COMPARE_OP_GREATER,depthClampEnableas needed). EngineMat4::perspective/lookAtare unchanged — the fix-up is local. - Winding. Front faces are clockwise engine-wide. Set
frontFace = VK_FRONT_FACE_CLOCKWISE,cullMode = BACK. (Watch: the Y-flip interacts with winding — verify on a known mesh in Phase 1, the same way the Metal probe bake flips to counterclockwise under an X-mirror.) - Coordinate/UV origin. Texture coordinate origin and cubemap face conventions differ from Metal; verify the equirect→cubemap bake and IBL faces against the Metal output in Phase 4.
Explicit work Metal hides (the bulk of the new code)
Metal's backend is ~2000 lines because the driver hides a lot. Budget for these Vulkan-only responsibilities (none change the render logic, only the plumbing):
- Instance + validation layers (debug), physical-device select, logical device + graphics/present queues.
- Swapchain + image views + recreation on
resize; per-frame sync (image- available / render-finished semaphores, in-flight fences); command pool/buffers. - Descriptor sets / layouts for every uniform + texture binding (Metal just
setBytes/setTextures by index). Plan a small set of descriptor-set layouts up front (per-frame, per-material, per-pass). - Explicit render passes / framebuffers (or dynamic rendering) for each target: shadow array, the MRT main pass (HDR color + view-normals + depth), and each post target.
- Memory allocation + barriers/layout transitions. Bring-up can do one allocation per resource; fold in pooling only if it bites (don't prematurely add VMA — it's a dependency, needs an ADR).
- Push constants vs UBOs: prefer push constants for tiny per-draw data
(model matrix index), UBOs/SSBOs for the big
LightUniforms/instance arrays (mirrors Metal'slightBufferchoice).
Build wiring (CMake, Phase 0)
In the viewer's non-Apple branch (CMakeLists.txt, currently the else() that
adds null_renderer.cpp):
find_package(Vulkan REQUIRED) # headers, loader, and glslc
list(APPEND VIEWER_SOURCES src/renderer/vulkan/vulkan_renderer.cpp)
set(PLATFORM_LIBS Vulkan::Vulkan)
# + a custom command compiling shaders/vulkan/*.{vert,frag,comp} -> *.spv
Keep NullRenderer as the fallback when Vulkan isn't found, so headless/CI
still links. Renderer::create() returns the Vulkan backend under the build
guard.
Phased bring-up (see docs/vulkan-renderer-plan.md for detail)
Each phase is independently verifiable on real Linux/Windows hardware with the validation layers on (no GPU in CI — same constraint Metal has).
- Device + swapchain → clear screen, clean validation log, resize works. (Code landed; verify on device.)
- First lit mesh → vertex/index upload, one UBO + descriptor set, depth,
common.metal→GLSL. Verify Y-orientation + depth vs Metal. (Code landed; verify on device — then enable back-face culling in Phase 2.) - Full forward pass → all lights, PBR + texture maps, the
applySurfacelibrary, instancing,drawTerrainCDLOD morph. - Cascaded shadows.
- Environment + IBL → procedural sky, equirect→cubemap bake, irradiance + prefilter + BRDF LUT, reflection probes.
- Post stack → SSAO (temporal) → SSR → bloom → tonemap+grade → lens+DOF,
one effect at a time, each verified before the next. Wire the
Rendererlive-tuning fields. - Parity sweep → side-by-side levels (forest, city_arena, an HDR scene) vs
Metal; optional
imgui_impl_vulkanbehindRT_ENABLE_IMGUI; gamepad via GLFW joystick (the GCController path is macOS-only).
Parity checklist (what "1:1 with Metal" means here)
Forward PBR (albedo/metallic/roughness/emission + 5 texture maps + per-vertex tint + analytic surfaces) · directional/point/spot lights (physical units) · cascaded shadows + PCF + artistic tint · procedural sky + day/night + FBM clouds · HDR cubemap + IBL (irradiance/prefilter/BRDF LUT) · reflection probes (parallax) · SSAO (temporal) · SSR · bloom · ACES/AgX tonemap + grade · lens (distortion/CA/vignette) + DOF · fog · instancing + wind · CDLOD terrain morph · alpha-tested foliage + depth prepass · debug views + wireframe.