scp-fs2open / scp-fs2open/fs2open.github.com

Use VK_KHR_dynamic_rendering

Open
#7,650 0 comments 0 reactions 1 assignee Claimed by @The-E View on GitHub
graphics refactor vulkan
Dominant language
C++
Stars
487
Forks
184
PR merge metrics
PR metrics pending

Description

## 1. Summary

`VK_KHR_dynamic_rendering` (core in Vulkan 1.3) replaces `VkRenderPass` + `VkFramebuffer` objects with a single `vkCmdBeginRendering()` call that names its attachments (image views, layouts, load/store ops, clear values) directly
at record time. Pipelines are created against a list of attachment *formats* (`VkPipelineRenderingCreateInfo`) instead of a render pass handle.

This maps unusually well onto how the FSO Vulkan renderer is already built:

- Every pass in the codebase is a **single-subpass** render pass. We use no subpass chaining, no input attachments, no `pResolveAttachments` (the MSAA G-buffer resolve is a fullscreen-shader pass writing depth via `gl_FragDepth`). The one render-pass feature we would lose — multi-subpass merging on tilers — is a feature we never used.
- Almost every pass exists **twice** (a `loadOp=eClear` and a `loadOp=eLoad` twin) purely because load ops are baked into `VkRenderPass`. Dynamic rendering makes loadOp a per-begin parameter and collapses all of these.
- The renderer already leans heavily on **explicit sync2 barriers** (`VulkanBarrier.h`, `ImageBarrier2`, `VK_KHR_synchronization2` is a hard requirement) for every mid-frame copy/transition. Only pass-boundary
transitions and cross-frame hazards ride on implicit render-pass machinery. The migration moves those onto the same explicit mechanism, leaving **one** synchronization system instead of two.

The migration deletes roughly 19 `VkRenderPass` objects, ~35 `VkFramebuffer` objects and their entire lifecycle management (creation, resize recreation, error-path cleanup, deletion queue traffic), and fixes a real existing inefficiency: the pipeline cache keys on the render pass *handle*, so compatible passes (clear/load twins, every render-to-texture target) silently
create duplicate pipelines today.

The main cost is that every implicit layout transition and every `VK_SUBPASS_EXTERNAL` dependency — several of which encode subtle, sync-validation-verified cross-frame hazard fixes — must be re-expressed as explicit `ImageBarrier2` calls. This is the risk center of the whole project and is treated in detail in §6.

---

## 2. Current state inventory

### 2.1 API baseline

| Item | Value | Source |
|---|---|---|
| Instance minimum | Vulkan 1.1 | `MinVulkanVersion`, `VulkanRendererSetup.cpp:30` |
| Requested API version | Vulkan 1.2 (`VK_API_VERSION_1_2`, in lockstep with VMA) | `VulkanMemory.h:31` |
| Required device extensions | `VK_KHR_swapchain`, `VK_KHR_synchronization2` (+ feature bit enforced) | `VulkanRendererSetup.cpp:68` |
| Optional extensions | shader_viewport_index_layer, hdr_metadata, acceleration_structure / ray_query / deferred_host_operations | `createLogicalDevice()` |
| MoltenVK | Supported (portability enumeration handled) | `initializeInstance()` |
| ImGui backend | Vendored `imgui_impl_vulkan` **already supports dynamic rendering** (`UseDynamicRendering` + `PipelineInfoMain.PipelineRenderingCreateInfo`) | `lib/imgui/backends/imgui_impl_vulkan.h` |

`VK_KHR_dynamic_rendering` as an extension requires Vulkan 1.1 + `VK_KHR_depth_stencil_resolve` + `VK_KHR_create_renderpass2`, both of which are core in 1.2 — so on our 1.2 baseline it is a single extension + one feature
bit (`VkPhysicalDeviceDynamicRenderingFeatures::dynamicRendering`). No API version bump is needed and VMA is unaffected.

### 2.2 Render pass census

Every render pass in the codebase, with the properties that matter for the migration (all single-subpass, all `eInline`):

| # | Pass | Owner | Attachments | Twins | External dependency encodes |
|---|---|---|---|---|---|
| 1 | Composition (clear) | `VulkanRenderer::m_renderPass` | fp16 color + shared depth | yes → #2 | cross-frame WAW on shared depth + composition color, WAR vs encode pass sampling |
| 2 | Composition (load) | `m_renderPassLoad` | same | — | same dependency; color `initialLayout=eShaderReadOnlyOptimal` → in-pass transition |
| 3 | Output encode | `m_encodeRenderPass` | swap chain image, `finalLayout=ePresentSrcKHR` | no | composition write → encode sample; `initialLayout=eUndefined` discard |
| 4 | HDR scene (clear) | `VulkanPostProcessor::m_sceneRenderPass` | RGBA16F + depth | yes → #5 | cross-frame WAW/WAR on scene color/depth (incl. post-processing sample + copy_effect transfer) |
| 5 | HDR scene (load) | `m_sceneRenderPassLoad` | same | — | same |
| 6 | G-buffer (clear) | `VulkanDeferredGBuffer` | 6 color + depth | yes → #7 | cross-frame WAW + transfer ordering |
| 7 | G-buffer (load) | — resume after mid-pass copy | same | — | same |
| 8 | MSAA G-buffer (clear) | 5 MS color + MS depth | yes → #9 | same class |
| 9 | MSAA G-buffer (load) | emissive preserved via per-attachment loadOp override | — | same |
| 10 | MSAA resolve | 5 single-sample color + depth (shader resolve, `gl_FragDepth`) | no | prior sample/resolve ordering (`useResolveDependency`) |
| 11 | MSAA emissive copy | 1 MS color | no | — |
| 12 | Light accumulation | `VulkanDeferredLighting` | composite RGBA16F, `loadOp=eLoad`, additive | no | — |
| 13 | Fog | `VulkanFog` | scene color, `loadOp=eDontCare`, `finalLayout=eColorAttachmentOptimal` | no | — |
| 14 | Bloom | `VulkanBloom::m_renderPass` | RGBA16F mip view, `eDontCare` | composite twin → #15 | yes (per-pass dep) |
| 15 | Bloom composite | scene color, `loadOp=eLoad`, additive | — | yes |
| 16 | LDR | `VulkanLDR::m_ldrRenderPass` (reused by SMAA) | RGBA8, `eDontCare` | load twin → #17 | — |
| 17 | LDR (load) | additive lightshafts etc. | — | — | — |
| 18 | Shadow map | `VulkanShadowMap` | depth-only D32F, **layered framebuffer** (`layers = cascade count`, VS layer output) | no | depth-clear ordering |
| 19 | Render-to-texture (per target!) | `tcache_slot_vulkan::renderPass` | RGBA8 (+ optional depth), `finalLayout=eShaderReadOnlyOptimal` | per-target load twin `renderPassLoad` | **none** (latent) |

Framebuffers: per-swap-image composition + encode framebuffers, scene FB, G-buffer FB, MSAA FB, resolve FB, emissive-copy FB, light-accum FB, fog FB, 2×4 bloom mip FBs + scene-color FB, 3 LDR FBs, 2 SMAA FBs, shadow layered FB, and one (or six, for cubemaps) per render-target texture.

### 2.3 How passes are begun

All frame-command-buffer passes go through `VulkanRenderer::beginTrackedRenderPass(PassBeginDesc)` (`VulkanRendererLoop.cpp:16`), which records `vkCmdBeginRenderPass` and syncs the state tracker (current pass handle, color attachment count, sample count, render area, viewport policy). Post-processing fullscreen passes instead begin/end their own short passes inside `PostProcessContext::drawFullscreenTriangle[Multi]()` (`VulkanPostProcessingCommon.cpp:81`), which also builds a `PipelineConfig` from the pass handle passed in.

### 2.4 How pipelines consume render passes

`PipelineConfig` (`VulkanPipeline.h`) carries `vk::RenderPass renderPass` + `subpass` + `colorAttachmentCount` + `sampleCount`; equality and the hash include the **raw handle** (`VulkanPipeline.cpp:52,126`), and `createPipeline()` plugs it into `pipelineInfo.renderPass` (`VulkanPipeline.cpp:473`). Draw-time code fills it from `stateTracker->getCurrentRenderPass()` (`VulkanDraw.cpp:735,1064`, `VulkanDeferred.cpp:941`, `VulkanPostProcessing.cpp:621`), and a few sites pass specific handles (`msaaResolveRenderPass()`, `irrTs->renderPass`, fog/light passes).

Consequence worth naming: **pipelines are duplicated today** whenever two compatible passes have different handles — every clear/load twin pair, and every render-to-texture target (each RT has its own `VkRenderPass` even though nearly all are `R8G8B8A8Unorm` + the same depth format). Format-keyed caching under dynamic rendering removes this class of duplication outright.

### 2.5 Synchronization split

Two mechanisms coexist today:

1. **Explicit sync2 barriers** for everything mid-frame: `copyEffectTexture`, `copySceneDepth`, mipmap generation, MSAA begin/resume transitions, distortion updates, readbacks. Helpers exist (`ImageBarrier2`, `cmdImageBarrier[s]`, `copyImageToImage` with layout-derived masks).
2. **Implicit render-pass machinery** for pass-boundary layout transitions (`initialLayout`/`finalLayout`) and cross-frame hazards
(`VK_SUBPASS_EXTERNAL` dependencies, each carefully commented and verified with `-gr_sync_validation`).

The split has already produced one platform workaround: `flip()` carries an `#ifdef __APPLE__` barrier because MoltenVK's translation of the *implicit* encode-pass dependency was historically unreliable (`VulkanRendererLoop.cpp:178`). Moving everything to explicit barriers eliminates that entire failure class.

---

## 3. Goals and non-goals

**Goals**

1. Replace all `VkRenderPass`/`VkFramebuffer` usage with `vkCmdBeginRendering`/`vkCmdEndRendering`.
2. Key pipeline creation and caching on attachment formats, deduplicating pipelines across formerly-distinct-but-compatible passes.
3. Unify all synchronization on explicit sync2 barriers; delete the MoltenVK special case.
4. Collapse every clear/load pass twin into per-begin load ops.
5. Keep the Vulkan 1.2 baseline (extension + feature bit, no version bump).

**Non-goals**

- No change to the pass *structure* of a frame (same passes, same order, same images). This is a mechanical-with-sharp-edges refactor, not a frame-graph redesign.
- No adoption of `VK_KHR_dynamic_rendering_local_read` (we have no input attachments to emulate) or suspend/resume rendering flags (see §5.6).
- No change to shaders, descriptor layout, vertex formats, or the OpenGL backend.

---

## 4. Availability decision

`VK_KHR_dynamic_rendering` coverage: all major desktop drivers since ~2022 (NVIDIA 510+, AMD 21.x+, Intel Mesa 22+ / Windows DCH), and MoltenVK since v1.2.x. Hardware old enough to lack it generally also struggles with the rest of the FSO Vulkan path.

**Recommendation: hard requirement.** The Vulkan backend is not yet FSO's shipping default; OpenGL remains available. If the extension (or its feature bit) is missing, fail Vulkan initialization with a clear log line, exactly as is done for `synchronization2` today (`isDeviceUnsuitable()`), letting launcher fallback to OpenGL handle the rest.

The alternative — keeping both code paths behind an abstraction — would make `beginTrackedRenderPass`, the pipeline cache key, and every barrier site permanently dual-mode. That's a large ongoing tax to support hardware that can still use the OpenGL backend. Rejected.

Concretely, Phase 0 adds:

- `VK_KHR_DYNAMIC_RENDERING_EXTENSION_NAME` to `RequiredDeviceExtensions`.
- `VkPhysicalDeviceDynamicRenderingFeatures` in the `pickPhysicalDevice()` feature chain + `isDeviceUnsuitable()` rejection, and chained into `vk::DeviceCreateInfo` in `createLogicalDevice()` (same pattern as `sync2Features`).
- vulkan.hpp dispatch already covers the KHR entry points via the dynamic loader; use the `KHR` suffixed calls (`cmd.beginRenderingKHR`) or plain aliases — with `VULKAN_HPP_DEFAULT_DISPATCHER` both resolve.

---

## 5. Design

### 5.1 `RenderingFormats` — the new pipeline compatibility key

```cpp
// VulkanPipeline.h
struct RenderingFormats {
uint32_t colorAttachmentCount = 1;
std::array colorFormats{}; // eUndefined past count
vk::Format depthFormat = vk::Format::eUndefined; // eUndefined = no depth attachment
vk::Format stencilFormat = vk::Format::eUndefined; // set when depthFormat has stencil
bool operator==(const RenderingFormats&) const;
void hashInto(uint64_t& h) const;
};
```

`PipelineConfig` changes:

- **Remove** `vk::RenderPass renderPass`, `uint32_t subpass`, and the separate
`colorAttachmentCount` (subsumed).
- **Add** `RenderingFormats formats`.
- `sampleCount` stays as-is.

`createPipeline()` builds:

```cpp
vk::PipelineRenderingCreateInfo renderingInfo;
renderingInfo.colorAttachmentCount = config.formats.colorAttachmentCount;
renderingInfo.pColorAttachmentFormats = config.formats.colorFormats.data();
renderingInfo.depthAttachmentFormat = config.formats.depthFormat;
renderingInfo.stencilAttachmentFormat = config.formats.stencilFormat;
pipelineInfo.pNext = &renderingInfo;
pipelineInfo.renderPass = nullptr;
```

**Stencil gotcha:** the chosen depth format may be `eD32SfloatS8Uint` or
`eD24UnormS8Uint` (`findDepthFormat()`). Under dynamic rendering the stencil
aspect is a *separate* format slot and a *separate*
`VkRenderingInfo::pStencilAttachment` (which must reference the same image
view as `pDepthAttachment`). Pipelines using stencil ops
(`config.stencilEnabled`, used by the stencil-decal path) will silently lose
stencil if `stencilAttachmentFormat` stays `eUndefined`. The
`RenderingFormats` builder must derive `stencilFormat` from the depth format's
aspect mask (`imageAspectFromFormat` already exists in `VulkanConvert.h`).

### 5.2 `DynamicPassDesc` — replacing `PassBeginDesc` + framebuffers

```cpp
// VulkanRenderer.h
struct PassAttachment {
vk::ImageView view;
vk::Format format = vk::Format::eUndefined;
vk::ImageLayout layout = vk::ImageLayout::eColorAttachmentOptimal; // layout DURING rendering
vk::AttachmentLoadOp loadOp = vk::AttachmentLoadOp::eLoad;
vk::AttachmentStoreOp storeOp = vk::AttachmentStoreOp::eStore;
vk::ClearValue clearValue{};
};

struct DynamicPassDesc {
std::array color;
uint32_t colorCount = 0;
PassAttachment depth; // format==eUndefined -> no depth
bool hasStencil = false; // mirror depth attachment into pStencilAttachment
vk::Extent2D extent;
uint32_t layerCount = 1; // shadow cascades use >1
vk::SampleCountFlagBits sampleCount = vk::SampleCountFlagBits::e1;
PassViewport viewport = PassViewport::FlipY;

RenderingFormats formats() const; // derived, feeds tracker + pipelines
};
```

`beginTrackedRenderPass(PassBeginDesc)` becomes
`beginTrackedRendering(const DynamicPassDesc&)`: builds
`vk::RenderingInfo` (+ per-attachment `vk::RenderingAttachmentInfo`), calls
`cmd.beginRendering()`, and updates the state tracker with
`desc.formats()` / sample count / render area / viewport exactly as today.
All eleven call sites in `VulkanRendererLoop.cpp` convert mechanically —
the clear/load twin selection collapses into choosing `loadOp` per attachment.

Deleted state:

- `VulkanRenderer`: `m_renderPass`, `m_renderPassLoad`, `m_encodeRenderPass`,
`m_swapChainFramebuffers`, `m_encodeFramebuffers` (keep the image views;
`createFrameBuffers()` and `createRenderPass()`/`createEncodeRenderPass()`
go away entirely).
- `VulkanPostProcessor` and each subsystem: every `vk::RenderPass` /
`vk::Framebuffer` member listed in §2.2, plus their creation code, resize
recreation (`VulkanBloom::resize()` etc. keep only image/view recreation),
and shutdown teardown.
- `tcache_slot_vulkan`: `renderPass`, `renderPassLoad`, `framebuffer`,
`cubeFaceFramebuffers` (keep `cubeFaceViews`; `beginRenderTarget()` selects
the face *view* instead of the face *framebuffer*). This deletes the two
large error-cleanup ladders in `VulkanTexture.cpp:1427-1545`.
- `VulkanDeletionQueue`: render pass / framebuffer deferred-destroy entry
points become unused; remove.

### 5.3 State tracker

`VulkanStateTracker`:

- `setRenderPass(vk::RenderPass, subpass)` → `setRenderingFormats(const RenderingFormats&)`.
- `getCurrentRenderPass()` → `getCurrentFormats()`; the existing null-check
idiom "are we inside a pass?" (`VulkanDraw.cpp:739`, `gr_vulkan.cpp:84`,
`VulkanDeferred.cpp:676`) becomes an explicit `bool isRenderingActive()`
flag toggled by begin/end — cleaner than inferring from a handle.
- `setColorAttachmentCount` / `setCurrentSampleCount` fold into the formats
call (attachment count is part of `RenderingFormats`).

Draw-time pipeline config assembly (`VulkanDraw.cpp`, `VulkanDeferred.cpp`,
`VulkanPostProcessing.cpp:621`) copies `getCurrentFormats()` instead of the
pass handle. Sites that name a specific pass today pass the target's formats:

- `VulkanDeferred.cpp:345` (MSAA resolve prewarm) → G-buffer format list.
- `VulkanDrawAPI.cpp:648` (irradiance RT) → `{ts->format, depth?}`.

### 5.4 `drawFullscreenTriangle` family

Signature change (`PostProcessContext`):

```cpp
void drawFullscreenTriangle(vk::CommandBuffer cmd,
const FullscreenTarget& target, // view+format+loadOp(+clearValue), extent
int shaderType, ...unchanged...);
```

where `FullscreenTarget` is a 1-color-attachment convenience wrapper that the
helper expands into a `DynamicPassDesc`-equivalent `RenderingInfo`. Callers
(bloom, LDR, SMAA, fog, tonemap, encode legs, MSAA emissive copy) each shrink:
today they pass `(renderPass, framebuffer)` pairs whose only real information
is *(image view, loadOp)* — exactly what the wrapper carries. The
`clearColor`/`vkCmdClearAttachments` workaround for discard-shaders (SMAA edge
detection) can become a genuine `loadOp=eClear` with a clear value, since
loadOp is now per-call — a small correctness/clarity win.

`sampleCount` and `bindGlobalSet` parameters are unchanged. The SMAA subsystem
no longer needs to "reuse VulkanLDR's render pass" — the coupling reduces to
reusing its *formats*, which is just `LDR_COLOR_FORMAT`.

### 5.5 Explicit barrier plan (replacing implicit machinery)

This is the load-bearing part. Two categories:

**(a) Pass-boundary layout transitions** (`initialLayout` → subpass layout →
`finalLayout`). Under dynamic rendering the image must *already be* in
`PassAttachment::layout` at `beginRendering`, and *stays there* at end. Each
transition becomes an `ImageBarrier2` before begin / after end:

| Today (implicit) | Replacement |
|---|---|
| Scene/G-buffer/RT/etc. `initialLayout=eUndefined` + clear | pre-begin barrier `eUndefined → eColorAttachmentOptimal` (discard), src = what §2.2 lists in that pass's external dependency, dst = `eColorAttachmentOutput` / early-frag |
| `finalLayout=eShaderReadOnlyOptimal` at end (scene color, G-buffer, shadow depth, RT color, composition, bloom mips, LDR) | post-end barrier `eColorAttachmentOptimal → eShaderReadOnlyOptimal` (or depth equivalent), dst = `eFragmentShader`/sampled-read placed at the point of first consumption |
| Encode pass `finalLayout=ePresentSrcKHR` | post-end barrier → `ePresentSrcKHR` (no dst access; present handles it) |
| Load-twin `initialLayout=eShaderReadOnlyOptimal → eColorAttachmentOptimal` in-pass transition (composition resume, scene resume) | pre-begin barrier; note `copySceneDepthForParticles()` **already does exactly this barrier by hand** (`VulkanRendererLoop.cpp:437-454`) — the pattern is established |

**(b) Cross-frame `VK_SUBPASS_EXTERNAL` dependencies.** These protect
single-instance images shared across frames-in-flight (shared depth buffer,
scene targets, G-buffer, composition image per swap-index). Each becomes a
barrier recorded immediately before the corresponding `beginRendering`, with
the same src/dst stage+access masks that the dependency carries today. The
extensive comments documenting *why* each mask bit exists
(`VulkanRenderer.cpp:344-372`, `VulkanPostProcessing.cpp:123-153`,
`VulkanPostProcessingGBuffer.cpp:85-127`) **must move with them** — they are
the distilled output of prior `-gr_sync_validation` debugging.

Implementation shape: a small helper in `VulkanBarrier.h`,

```cpp
// One barrier struct per attachment about to be rendered to.
void cmdBeginRenderingBarriers(vk::CommandBuffer, ArrayView);
```

plus per-pass constexpr-ish builder functions colocated with the pass code
(e.g. `VulkanDeferredGBuffer::beginBarriers(bool resuming)`), so each former
render pass's implicit behavior is visible as one block of explicit barriers
next to its begin call.

**Deliberately not in scope:** an automatic per-image layout tracker. It's
tempting (RenderTarget could carry `currentLayout`), but correct barriers need
src *stage/access* knowledge, not just layout — an automatic tracker that only
watches layouts would emit under-synchronized barriers for the cross-frame WAW
cases (same layout, still needs execution ordering). The manual per-pass
blocks keep that reasoning local and auditable. A tracker can be revisited
later as a pure refactor once the explicit barriers are proven by validation.

Also note asymmetric wins:

- The RT-texture passes currently have **no** external dependency at all
(§2.2 #19) — their cross-frame WAW protection is implicit-and-accidental
(mid-frame submits / fence waits). Writing their barriers explicitly closes
a latent hazard rather than opening one.
- The `#ifdef __APPLE__` composition barrier in `flip()` becomes the *normal*
path for all platforms; delete the conditional and its long apology comment.

### 5.6 Mid-scene suspend/resume

`copyEffectTexture()` / `copySceneDepthForParticles()` end the scene pass, do
transfer work + barriers, and resume with the load twin. Dynamic rendering has
`VK_RENDERING_SUSPENDING/RESUMING_BIT`, but the spec forbids most commands —
including barriers and transfers — between a suspended and resumed instance,
so it is unusable here. The design keeps the current shape: full
`endRendering`, explicit copy/barriers, `beginRendering` with
`loadOp=eLoad`. This is exactly what the load twins do today, minus the twin
objects.

### 5.7 Shadow pass (layered)

The shadow map renders all cascades in one pass via a layered framebuffer
(`layers = Num_shadow_cascades + Num_cockpit_shadow_cascades`) and
VS `gl_Layer` output. Dynamic rendering: the depth attachment uses the
existing 2D-array view and `RenderingInfo::layerCount = layers`,
`viewMask = 0`. No behavioral change; `VulkanShadowMap` loses `m_renderPass` +
`m_framebuffer`.

### 5.8 ImGui

`initImGui()` switches to:

```cpp
initInfo.UseDynamicRendering = true;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo = {
VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR, nullptr, 0,
1, &compositionFormatVk, // HDR_COLOR_FORMAT
depthFormatVk, stencilFormatVk, // matches the composition pass
};
```

(the backend requires the extension to be enabled even on 1.3 devices — it is,
per Phase 0). `invalidateExternalBindings()` interplay is unchanged; ImGui
draws inside the composition rendering instance exactly as it does inside the
composition render pass today.

### 5.9 Pipeline caching

- **App-level cache** (`m_pipelines` map): key changes with `PipelineConfig`;
no persistence concern (in-memory).
- **Driver-level `VkPipelineCache`** (persisted to disk): entries created via
render-pass pipelines generally won't be reused byte-for-byte by
dynamic-rendering pipelines, so first run after the migration re-warms.
That's a one-time hitch identical to a driver update; no code change needed.
Keep the cache file name unchanged.
- Expected *win*: RT-texture pipelines collapse from per-target to per-format;
clear/load twin duplicates disappear. Log `getPipelineCount()` before/after
in a soak test to quantify.

---

## 6. Risks

| Risk | Severity | Mitigation |
|---|---|---|
| Missed cross-frame hazard when converting an external dependency (renders fine on forgiving desktop drivers, corrupts on others/timing shifts) | **High** | Convert dependencies 1:1 including masks, keep their comments; run every migration phase under `-gr_sync_validation` (the codebase's stated verification tool); test matrix in §8 |
| Stencil silently dropped (missing `stencilAttachmentFormat` / `pStencilAttachment` on D24S8/D32S8 devices) | Medium | §5.1 builder derives stencil from depth format; add an assertion in `createPipeline` when `stencilEnabled && stencilFormat == eUndefined` |
| MoltenVK behavioral differences in `beginRendering` translation | Medium | Dev machine is macOS — MoltenVK is exercised continuously; explicit barriers are *more* reliable on MoltenVK than implicit dependencies were (per the existing `flip()` comment) |
| Tiler performance regressions (load/store now per-begin) | Low | We already use `eDontCare`/`eLoad` deliberately everywhere; `RenderingAttachmentInfo` expresses identical load/store ops, and we never used multi-subpass merging |
| Old-driver support loss (pre-2022 desktop) | Low | Hard-requirement decision in §4; OpenGL backend remains |
| ImGui backend API drift | Low | Vendored backend already has the API; pin behavior with the existing overlay smoke test |
| Transient duplicate pipelines during phased migration (formats-keyed and handle-keyed configs coexist) | Low | Accept; memory-only, disappears at Phase 4 |

---

## 7. Migration plan

Phases are independently landable; dynamic rendering instances and classic
render passes may coexist in one command buffer (not nested), so the renderer
stays shippable between phases. Pipelines, however, are per-mechanism — a pass
converts atomically with the pipelines used inside it.

**Phase 0 — Enablement (small).**
Extension + feature negotiation, `isDeviceUnsuitable()` gate, log line.
No behavior change. Also land `RenderingFormats` + the `PipelineConfig` dual
support: a config with `renderPass == null` and populated formats produces a
dynamic-rendering pipeline; existing paths untouched.

**Phase 1 — Post-processing fullscreen passes.**
Convert `drawFullscreenTriangle[Multi]` to the `FullscreenTarget` form; this
flips bloom (#14–15), LDR/FXAA/lightshafts/post-effects (#16–17), SMAA, fog
(#13), tonemap, MSAA emissive copy (#11), and both encode legs (#3) in one
motion, because they all begin their passes through that helper. These passes
are already bracketed by explicit barriers on their inputs, so the *new*
barrier work is limited to each target's own begin/end transitions. Delete the
corresponding pass/FB members subsystem by subsystem.

**Phase 2 — Render-to-texture.**
`VulkanTexture.cpp` target creation drops render pass/framebuffer creation;
`beginRenderTarget`/`resumeRenderTargetPass`/`endRenderTarget` move to
`DynamicPassDesc` with explicit begin/end barriers (closing the latent
cross-frame gap noted in §5.5). Covers cubemap faces (per-face views),
mid-frame `readbackRenderTarget` flush/resume, env/irradiance mip regen.

**Phase 3 — Scene, G-buffer, shadow, light accumulation.**
The mid-frame suspend/resume web: `beginSceneRendering`,
`resumeScenePassAfterCopy`, `endSceneRendering`, `setUseGbufRenderPass`
consumers, MSAA begin/resume/resolve (#4–10, #12, #18). Largest barrier
surface; do G-buffer non-MSAA first, then MSAA, then shadows. The existing
`transitionForResume`/`transitionMsaaForBegin` helpers already model exactly
this style of explicit transition and extend naturally.

**Phase 4 — Frame skeleton + ImGui + cleanup.**
Composition pass begin in `setupFrame`, resume in `endSceneRendering`/
`resumeSwapChainPass`, encode-into-swap-chain, ImGui init switch, deletion of
`PassBeginDesc`, `createRenderPass*`, `createFrameBuffers`, the `__APPLE__`
barrier, deletion-queue entry points, and the now-dead state-tracker render
pass accessors. Grep gate: `git grep -l 'RenderPass\|Framebuffer' code/graphics/vulkan/`
should return only comments/docs.

Rough size estimate: ~1.5–2.5k changed lines across ~22 files, dominated by
Phase 3.

---

## 8. Validation matrix

Every phase, on at least one desktop driver + MoltenVK, with
`-gr_sync_validation` enabled:

- Forward scene (deferred off) and deferred scene; MSAA off/2×/4×.
- Shadows on (cascade count > 1 exercises `layerCount`), raytraced shadows
where supported (BLAS work is outside passes — should be inert, verify).
- HDR10 swap chain on/off (both encode legs), gamma slider.
- Mid-scene interrupts: distortion/thrusters (copyEffectTexture), soft
particles (copySceneDepthForParticles), decals (stencil path — verifies
§5.1 stencil formats).
- RTT: SCPUI icon generation incl. mid-frame `screenToBlob` readback +
resume; env/irradiance cubemap render + mip regen.
- FXAA and SMAA paths (SMAA verifies the loadOp=eClear conversion), bloom,
lightshafts, volumetric fog.
- `gr_save_screen` / `readbackFramebuffer` (previous-frame composition read).
- Swap chain recreation: resize, minimize-to-0×0, fullscreen toggle;
`VulkanPostProcessor::resize()` (which now recreates only images/views).
- ImGui overlay (`invalidateExternalBindings` path).
- Pipeline count before/after (expect a drop), first-frame hitch check after
cache invalidation.

---

## 9. Open questions

1. **Minimum-spec statement.** Does the project want to publish a driver
floor for the Vulkan backend (e.g. "2022+ drivers / MoltenVK 1.2.2+")
alongside the hard requirement? Recommended, cheap.
2. **`VulkanRaytracing` interplay.** BLAS builds and ray queries never touch
render passes, so no changes are expected — but the shadow ray-query path
samples G-buffer products whose transitions move; re-verify its barriers
during Phase 3 rather than assuming.
3. **Follow-ups unlocked** (explicitly out of scope, listed for the record):
`VK_EXT_shader_object`, dynamic-rendering-based VRS, and collapsing the
remaining per-pass pipeline permutations via more dynamic state
(`VK_EXT_extended_dynamic_state3`) all become tractable once pipelines are
format-keyed.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.