diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index 2d955a83..76897682 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -398,7 +398,7 @@ commit's build, capture again at slice HEAD, compare with the
| Slice | Scope | Gate |
|---|---|---|
| **V0** ✅ | Pinned RHI contract, `RecordingGpuDevice`, contract tests, this document, roadmap entry. | build + tests + contract tests |
-| **V1** | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, the ambient-encoder relaxation, timers, `IGpuFenceApi` re-home, backbuffer capture. Constructed in composition; no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) |
+| **V1** | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, timers, backbuffer capture. Constructed in composition; no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) |
| **V2** | Shader dialect + texture-index migration **on GL**: `uvec2 textureHandle` → `uint textureIndex`, binding-9 table, `common.glsl` preamble, CPU batch-struct change, caches registering into the device table. Sub-commits: V2a mesh, V2b terrain, V2c particles. | pixel gate per sub-commit |
| **V3** | Clip-space and sRGB audit: verify every projection producer is [0,1] convention, confirm clip-plane derivation, record the sRGB swapchain decision and the depth-precision divergence class here. | pixel gate + connected lifecycle |
| **V4a** | `TextRenderer` (three fence-buffered VBO sets → ring allocations), `BitmapFont`, `DebugLineRenderer`, the UI RenderSurface upload path, `UiViewport`'s texture handoff. | pixel gate (UI-heavy checkpoints) |
@@ -408,7 +408,7 @@ commit's build, capture again at slice HEAD, compare with the
| **V4e** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) |
| **V4f** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) |
| **V4g** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit |
-| **V4h** | Frame-spine formalization: pass executors emit `BeginPass`/`EndPass`, the ambient relaxation is removed, flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) |
+| **V4h** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) |
| **V5** | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 |
| **V6** | Vulkan RHI backend, dark, three sequential commits: **a** allocator/buffers/staging/rings/timeline; **b** textures/BC mips/samplers/descriptor table/render targets/MSAA resolve; **c** `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names. **Milestone: full game frame on Vulkan.** | per-commit build + tests; VK renders world, UI, paperdoll, portals |
| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK |
diff --git a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
index f9fd91e2..96b2740a 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
@@ -42,10 +42,15 @@ internal readonly record struct GpuDepthAttachment(
///
/// GL has no such object — its "pass" is implicit in whatever framebuffer happens
/// to be bound — so making passes explicit is the single largest structural change
-/// the RHI imposes on the existing renderers. The GL backend therefore accepts an
-/// ambient encoder during Campaign V slices V1..V4g (draws outside any declared
-/// pass, preserving today's behaviour) and slice V4h removes that relaxation once
-/// every renderer declares its passes.
+/// the RHI imposes on the existing renderers.
+///
+/// The transitional path needs no special API. On GL, BeginPass binds the
+/// target framebuffer and applies load ops, and deliberately does NOT touch
+/// viewport or scissor — encoders set those explicitly. A renderer being ported
+/// during Campaign V slices V4a..V4g therefore opens a pass with
+/// / against the
+/// backbuffer and gets exactly today's behaviour, while the frame spine still
+/// owns clears and framebuffer management. Slice V4h converts the spine itself.
///
internal sealed record GpuPassDescription
{