diff --git a/OPENXR.md b/OPENXR.md index c148b90..f582822 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -48,6 +48,7 @@ hud_virtual_screen = true flat_screen = false stop_at_display_copy = true skip_copy_clears = true +single_pass_eyes = true first_person = false first_person_toggle_click = true first_person_seat = "cockpit" @@ -161,6 +162,13 @@ panel's rectangle), at the same size per pixel, so nothing moves. `stop_at_display_copy` ends eye replay at the final `GXCopyDisp`, matching the frame shown on the desktop. `skip_copy_clears` independently suppresses the EFB reset performed after a copy. Both default on and can be changed live from the F10 settings bar for diagnostics. +`single_pass_eyes` draws each eye in one render pass. The desktop image ends a render pass at every +GX copy, because the copy reads what was drawn before it; an eye samples the copies the desktop +image made and never performs them, so it keeps drawing in the pass it has open, and it leaves out +whatever a later clear of the whole color and depth erases. The picture is the same with less GPU +memory traffic, which a tiled mobile GPU pays for at every split (the "Eye replay plan" log line +reports each new pass structure). It defaults on and is live; turning it off replays one render pass +per recorded pass. `first_person` and the `first_person_*` values are the first-person camera described below. All four are live and are also exposed in the F10 settings bar. `performance_level` is the level asked of the runtime through `XR_EXT_performance_settings` for diff --git a/aurora-main/include/aurora/gfx.h b/aurora-main/include/aurora/gfx.h index 0e4c87a..cd7336c 100644 --- a/aurora-main/include/aurora/gfx.h +++ b/aurora-main/include/aurora/gfx.h @@ -99,6 +99,13 @@ void aurora_set_stereo_stop_at_display_copy(bool enabled); bool aurora_get_stereo_stop_at_display_copy(); void aurora_set_stereo_skip_copy_clears(bool enabled); bool aurora_get_stereo_skip_copy_clears(); +// single_pass_eyes keeps drawing an eye in the render pass it has open across +// the frame's GX copies, which only the mono render performs, and leaves out +// passes a later clear of the whole EFB erases. The image is the same with +// fewer tile loads and stores; on by default, live, and off replays one render +// pass per recorded pass. +void aurora_set_stereo_single_pass_eyes(bool enabled); +bool aurora_get_stereo_single_pass_eyes(); // Places orthographic GX draws (menus, HUD, 2D overlays) on a fixed virtual // screen during immersive replay instead of stretching them across the whole diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index f5e4c35..c4307f2 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -2857,6 +2857,8 @@ void aurora_set_stereo_stop_at_display_copy(bool enabled) { aurora::gfx::set_ste bool aurora_get_stereo_stop_at_display_copy() { return aurora::gfx::get_stereo_stop_at_display_copy(); } void aurora_set_stereo_skip_copy_clears(bool enabled) { aurora::gfx::set_stereo_skip_copy_clears(enabled); } bool aurora_get_stereo_skip_copy_clears() { return aurora::gfx::get_stereo_skip_copy_clears(); } +void aurora_set_stereo_single_pass_eyes(bool enabled) { aurora::gfx::set_stereo_single_pass_eyes(enabled); } +bool aurora_get_stereo_single_pass_eyes() { return aurora::gfx::get_stereo_single_pass_eyes(); } void aurora_set_stereo_hud_screen(bool enabled, float width, float distance) { aurora::gfx::set_stereo_hud_screen(enabled, width, distance); } diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index 2214df6..31200ab 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -4,6 +4,7 @@ #include "clear.hpp" #include "depth_peek.hpp" #include "efb_ram_copy.hpp" +#include "eye_pass_plan.hpp" #include "../internal.hpp" #include "../webgpu/gpu.hpp" #include "../gx/pipeline.hpp" @@ -217,6 +218,9 @@ static u32 g_currentRenderPass = UINT32_MAX; // for A/B comparison without a rebuild. static std::atomic_bool g_stereoStopAtDisplayCopy{true}; static std::atomic_bool g_stereoSkipCopyClears{true}; +// Replays each eye in as few render passes as its clears allow (eye_pass_plan.hpp) rather than one +// per recorded pass. Same image, fewer tile loads and stores. +static std::atomic_bool g_stereoSinglePassEyes{true}; void set_stereo_stop_at_display_copy(bool value) noexcept { g_stereoStopAtDisplayCopy.store(value, std::memory_order_relaxed); @@ -226,6 +230,10 @@ void set_stereo_skip_copy_clears(bool value) noexcept { g_stereoSkipCopyClears.store(value, std::memory_order_relaxed); } bool get_stereo_skip_copy_clears() noexcept { return g_stereoSkipCopyClears.load(std::memory_order_relaxed); } +void set_stereo_single_pass_eyes(bool value) noexcept { + g_stereoSinglePassEyes.store(value, std::memory_order_relaxed); +} +bool get_stereo_single_pass_eyes() noexcept { return g_stereoSinglePassEyes.load(std::memory_order_relaxed); } // The fixed virtual screen orthographic draws are placed on during immersive // replay, in game world units. Written from the settings overlay and read by @@ -1766,14 +1774,111 @@ struct RenderInvocation { cockpit::SceneDepth cockpitDepth{}; bool* cockpitDrawn = nullptr; bool* sceneDrawn = nullptr; + // An eye replay laid out by eye_pass_plan, replacing the one-render-pass-per-recorded-pass loop. + const eye_pass_plan::Plan* eyePlan = nullptr; }; static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vector& passes, u32 idx, const RenderInvocation& invocation); +// Replays one eye as its plan lays it out. Each step's commands go through render_pass_impl exactly +// as render_impl's loop replays them; only where render passes begin and end differs. +static void render_eye_planned(std::vector& renderPasses, wgpu::CommandEncoder& cmd, + const RenderInvocation& invocation, const eye_pass_plan::Plan& plan) { + const ReplayTarget& target = *invocation.target; + const bool stereoStencil = target.depthFormat == wgpu::TextureFormat::Depth24PlusStencil8; + const GpuTimingCategory timingCategory = + invocation.stereoEye == 0 ? GpuTimingCategory::EyeLeft : GpuTimingCategory::EyeRight; + wgpu::RenderPassEncoder pass; + for (const auto& step : plan.steps) { + const auto& passInfo = renderPasses[step.pass]; + if (step.begin) { + if (pass) { + pass.End(); + } + const wgpu::RenderPassColorAttachment colorAttachment{ + .view = target.colorView, + .resolveTarget = target.resolveView, + .loadOp = step.clearColor ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load, + .storeOp = wgpu::StoreOp::Store, + .clearValue = + { + .r = passInfo.clearColorValue.x(), + .g = passInfo.clearColorValue.y(), + .b = passInfo.clearColorValue.z(), + .a = passInfo.clearColorValue.w(), + }, + }; + const wgpu::RenderPassDepthStencilAttachment depthStencilAttachment{ + .view = target.depthView, + .depthLoadOp = step.clearDepth ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load, + .depthStoreOp = wgpu::StoreOp::Store, + .depthClearValue = passInfo.clearDepthValue, + .stencilLoadOp = stereoStencil ? (step.clearStencil ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load) + : wgpu::LoadOp::Undefined, + .stencilStoreOp = stereoStencil ? wgpu::StoreOp::Store : wgpu::StoreOp::Undefined, + .stencilClearValue = 0, + }; + const wgpu::RenderPassDescriptor renderPassDescriptor{ + .label = render_pass_label(step.pass), + .colorAttachmentCount = 1, + .colorAttachments = &colorAttachment, + .depthStencilAttachment = &depthStencilAttachment, + .timestampWrites = gpu_timing_pass(timingCategory), + }; + pass = cmd.BeginRenderPass(&renderPassDescriptor); + } else { + // Carry on from the state a fresh render pass starts in: render_pass_impl assumes the whole eye + // as viewport and scissor, and a clear draw leaves its colour behind as the blend constant. + const wgpu::Color noBlendConstant{0.0, 0.0, 0.0, 0.0}; + pass.SetViewport(0.0f, 0.0f, static_cast(target.size.width), static_cast(target.size.height), + 0.0f, 1.0f); + pass.SetScissorRect(0, 0, target.size.width, target.size.height); + pass.SetBlendConstant(&noBlendConstant); + pass.SetStencilReference(0); + } + render_pass_impl(pass, renderPasses, step.pass, invocation); + } + if (!pass) { + return; + } + // The cockpit that no virtual-screen draw brought in goes over the finished world here rather than + // in a render pass of its own (render_stereo_eye's fallback), which would load the eye back. + if (invocation.cockpitFrame != nullptr && !*invocation.cockpitDrawn) { + cockpit::render(*invocation.cockpitEncoder, *invocation.cockpitFrame, invocation.stereoEye, + invocation.cockpitDepth, &pass); + *invocation.cockpitDrawn = true; + } + pass.End(); +} + +static void finish_render_impl(std::vector& renderPasses, const RenderInvocation& invocation) { + if (invocation.finalize) { + recycle_render_passes(renderPasses); + } + +#if defined(AURORA_GFX_DEBUG_GROUPS) + if (invocation.finalize && !g_debugGroupStack.empty()) { + for (auto& it : std::ranges::reverse_view(g_debugGroupStack)) { + Log.warn("Debug group was not popped at end of frame: {}", it); + } + g_debugGroupStack.clear(); + } + + if (invocation.finalize && g_debugMarkers.size() > 0) { + g_debugMarkers.clear(); + } +#endif +} + static void render_impl(std::vector& renderPasses, wgpu::CommandEncoder& cmd, const RenderInvocation& invocation) { ZoneScoped; + if (invocation.eyePlan != nullptr) { + render_eye_planned(renderPasses, cmd, invocation, *invocation.eyePlan); + finish_render_impl(renderPasses, invocation); + return; + } // Palette conversions, MSAA resolves and EFB copies depend on sealed frame state, not on the // interpolation weight, so encode them on the native render and let replay slots sample them. // Eye textures are reused; discard the previous frame's mask, then retain it @@ -1929,22 +2034,7 @@ static void render_impl(std::vector& renderPasses, wgpu::CommandEnco } } } - if (invocation.finalize) { - recycle_render_passes(renderPasses); - } - -#if defined(AURORA_GFX_DEBUG_GROUPS) - if (invocation.finalize && !g_debugGroupStack.empty()) { - for (auto& it : std::ranges::reverse_view(g_debugGroupStack)) { - Log.warn("Debug group was not popped at end of frame: {}", it); - } - g_debugGroupStack.clear(); - } - - if (invocation.finalize && g_debugMarkers.size() > 0) { - g_debugMarkers.clear(); - } -#endif + finish_render_impl(renderPasses, invocation); } void seal_frame(SealedFrame& out) noexcept { @@ -2059,6 +2149,39 @@ bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, c return true; } +// This frame's eye plan, built in scratch storage that is reused from frame to frame. Each new plan +// shape is logged once, so a headset log shows the pass structure of every scene it went through. +static const eye_pass_plan::Plan& plan_eye_passes(const std::vector& passes, int32_t lastPass, + bool skipCopyClears) { + thread_local std::vector summaries; + thread_local eye_pass_plan::Plan plan; + summaries.resize(passes.size()); + for (size_t i = 0; i < passes.size(); ++i) { + const auto& pass = passes[i]; + summaries[i] = eye_pass_plan::PassSummary{ + .efbTarget = pass.efbTarget, + .clearColor = pass.clearColor, + .clearDepth = pass.clearDepth, + .postCopyClear = pass.postCopyClear, + .hasCommands = !pass.commands.empty(), + }; + } + eye_pass_plan::build(summaries.data(), summaries.size(), lastPass, skipCopyClears, plan); + + static std::mutex loggedShapesMutex; + static std::vector> loggedShapes; + const std::array shape{plan.efbPasses, plan.renderPasses, plan.dead, plan.empty, plan.splits}; + std::lock_guard lock{loggedShapesMutex}; + if (loggedShapes.size() < 32 && std::ranges::find(loggedShapes, shape) == loggedShapes.end()) { + loggedShapes.push_back(shape); + Log.info("Eye replay plan: {} EFB passes -> {} render pass{} per eye ({} erased by a later clear, {} empty, " + "{} split by a partial clear)", + plan.efbPasses, plan.renderPasses, plan.renderPasses == 1 ? "" : "es", plan.dead, plan.empty, + plan.splits); + } + return plan; +} + void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame, uint32_t eye, bool finalize) { CHECK(eye < AURORA_STEREO_EYE_COUNT, "invalid stereo eye {}", eye); @@ -2079,6 +2202,9 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster bool cockpitDrawn = false; bool sceneDrawn = false; const bool cockpitActive = stereoFrame.cockpit.active && cockpitDepth.valid; + const bool skipCopyClears = get_stereo_skip_copy_clears(); + const eye_pass_plan::Plan* plan = + get_stereo_single_pass_eyes() ? &plan_eye_passes(frame.data().passes, lastPass, skipCopyClears) : nullptr; render_impl(frame.data().passes, cmd, RenderInvocation{ .stereoEye = eye, @@ -2088,7 +2214,7 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster .replayLastPass = lastPass, .finalize = finalize, .replayOnlyEfb = true, - .skipCopyClears = get_stereo_skip_copy_clears(), + .skipCopyClears = skipCopyClears, .encodeTextureBakes = false, .encodeResolves = false, .captureDepth = false, @@ -2097,9 +2223,11 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster .cockpitDepth = cockpitDepth, .cockpitDrawn = &cockpitDrawn, .sceneDrawn = &sceneDrawn, + .eyePlan = plan, }); // A frame without a virtual-screen draw after its world still gets the - // overlay, in a pass of its own over the finished eye. + // overlay, in a pass of its own over the finished eye. A planned eye draws it + // in its last render pass instead, unless it had nothing to render at all. if (cockpitActive && !cockpitDrawn) { cockpit::render(cmd, stereoFrame, eye, cockpitDepth); } diff --git a/aurora-main/lib/gfx/common.hpp b/aurora-main/lib/gfx/common.hpp index 7e7cc26..16f72b0 100644 --- a/aurora-main/lib/gfx/common.hpp +++ b/aurora-main/lib/gfx/common.hpp @@ -405,6 +405,10 @@ void set_stereo_stop_at_display_copy(bool value) noexcept; bool get_stereo_stop_at_display_copy() noexcept; void set_stereo_skip_copy_clears(bool value) noexcept; bool get_stereo_skip_copy_clears() noexcept; +// Replays each eye in as few render passes as its clears allow (gfx/eye_pass_plan.hpp). On by +// default and live, like the two above. +void set_stereo_single_pass_eyes(bool value) noexcept; +bool get_stereo_single_pass_eyes() noexcept; // Places orthographic draws on a fixed virtual screen during immersive replay. // `width` and `distance` are in game world units; the screen's height follows diff --git a/aurora-main/lib/gfx/eye_pass_plan.hpp b/aurora-main/lib/gfx/eye_pass_plan.hpp new file mode 100644 index 0000000..87af8a8 --- /dev/null +++ b/aurora-main/lib/gfx/eye_pass_plan.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include + +// How an immersive eye replays the frame's main-EFB passes (gfx::render_stereo_eye). +// +// The mono render ends a render pass at every GX copy, because the copy reads what was drawn +// before it. An eye never performs those copies: it samples what the mono render baked. So it can +// keep drawing in the render pass it has open across them. On a tiled GPU every split stores the +// whole eye and loads it back, and a fragment density map makes that load even costlier (DolphinXR +// measured foveation as a net loss on Mario Kart Wii for exactly this reason). +namespace aurora::gfx::eye_pass_plan { + +// What the plan needs from one recorded pass. +struct PassSummary { + // The pass draws into the main EFB, which an eye replays into its own target. + bool efbTarget = false; + // Attachment clears of the whole target (all of RGBA for color). + bool clearColor = false; + bool clearDepth = false; + // The continuation after a GXCopyDisp, whose clears are that copy's EFB reset. + bool postCopyClear = false; + bool hasCommands = false; +}; + +struct Step { + // The recorded pass whose commands this step replays. + uint32_t pass = 0; + // Begin a render pass with these load ops; otherwise keep drawing in the open one. + bool begin = false; + bool clearColor = false; + bool clearDepth = false; + // Only an eye's first render pass clears its stencil, the VR cockpit's mask. + bool clearStencil = false; +}; + +struct Plan { + std::vector steps; + // Recorded EFB passes up to the replay's last pass. + uint32_t efbPasses = 0; + // EFB passes whose pixels a later clear of the whole color and depth erases. + uint32_t dead = 0; + // Passes left with nothing to draw or clear. + uint32_t empty = 0; + // Render passes begun, and how many of them a clear of only color or only depth forced after + // the first. + uint32_t renderPasses = 0; + uint32_t splits = 0; +}; + +// `lastPass` is the inclusive index of the last recorded pass to replay, or -1 for all of them. +// `skipCopyClears` drops a GXCopyDisp's EFB reset, as the eye replay does +// (set_stereo_skip_copy_clears). +inline void build(const PassSummary* passes, size_t count, int32_t lastPass, bool skipCopyClears, Plan& plan) { + plan.steps.clear(); + plan.efbPasses = 0; + plan.dead = 0; + plan.empty = 0; + plan.renderPasses = 0; + plan.splits = 0; + const size_t end = lastPass >= 0 ? std::min(count, static_cast(lastPass) + 1) : count; + const auto clears = [skipCopyClears](const PassSummary& pass, bool& color, bool& depth) { + const bool dropped = skipCopyClears && pass.postCopyClear; + color = pass.clearColor && !dropped; + depth = pass.clearDepth && !dropped; + }; + + // The mono render still draws every pass for the copies the game samples; in the eye, anything + // drawn before the last clear of both color and depth is erased by it. + size_t first = 0; + for (size_t i = 0; i < end; ++i) { + if (!passes[i].efbTarget) { + continue; + } + ++plan.efbPasses; + bool color = false; + bool depth = false; + clears(passes[i], color, depth); + if (color && depth) { + first = i; + } + } + + for (size_t i = 0; i < end; ++i) { + const PassSummary& pass = passes[i]; + if (!pass.efbTarget) { + continue; + } + if (i < first) { + ++plan.dead; + continue; + } + bool color = false; + bool depth = false; + clears(pass, color, depth); + if (!color && !depth && !pass.hasCommands) { + ++plan.empty; + continue; + } + // After the last full clear, only a clear of color alone or depth alone still needs a load op; + // every other continuation keeps drawing where the previous pass left off. + const bool begin = plan.renderPasses == 0 || color || depth; + plan.steps.push_back(Step{ + .pass = static_cast(i), + .begin = begin, + .clearColor = color, + .clearDepth = depth, + .clearStencil = begin && plan.renderPasses == 0, + }); + if (begin) { + ++plan.renderPasses; + } + } + plan.splits = plan.renderPasses > 0 ? plan.renderPasses - 1 : 0; +} + +} // namespace aurora::gfx::eye_pass_plan diff --git a/aurora-main/tests/CMakeLists.txt b/aurora-main/tests/CMakeLists.txt index 3633746..aa6b23a 100644 --- a/aurora-main/tests/CMakeLists.txt +++ b/aurora-main/tests/CMakeLists.txt @@ -55,6 +55,7 @@ if (AURORA_ENABLE_GX) stereo_replay_test.cpp stereo_interpolation_test.cpp stereo_mirror_test.cpp + eye_pass_plan_test.cpp native_wheel_test.cpp cockpit_geometry_test.cpp texture_bind_group_cache_key_test.cpp diff --git a/aurora-main/tests/eye_pass_plan_test.cpp b/aurora-main/tests/eye_pass_plan_test.cpp new file mode 100644 index 0000000..baace90 --- /dev/null +++ b/aurora-main/tests/eye_pass_plan_test.cpp @@ -0,0 +1,175 @@ +#include "gfx/eye_pass_plan.hpp" + +#include + +#include + +namespace aurora::gfx::eye_pass_plan { +namespace { + +PassSummary efb(bool clearColor, bool clearDepth, bool hasCommands = true, bool postCopyClear = false) { + return PassSummary{ + .efbTarget = true, + .clearColor = clearColor, + .clearDepth = clearDepth, + .postCopyClear = postCopyClear, + .hasCommands = hasCommands, + }; +} + +PassSummary offscreen() { + return PassSummary{.efbTarget = false, .clearColor = true, .clearDepth = true, .hasCommands = true}; +} + +Plan build(const std::vector& passes, int32_t lastPass = -1, bool skipCopyClears = true) { + Plan plan; + eye_pass_plan::build(passes.data(), passes.size(), lastPass, skipCopyClears, plan); + return plan; +} + +TEST(EyePassPlan, ContinuationsAfterCopiesShareOneRenderPass) { + const Plan plan = build({efb(true, true), efb(false, false), efb(false, false), efb(false, false)}); + ASSERT_EQ(plan.steps.size(), 4u); + EXPECT_EQ(plan.efbPasses, 4u); + EXPECT_EQ(plan.renderPasses, 1u); + EXPECT_EQ(plan.splits, 0u); + EXPECT_TRUE(plan.steps[0].begin); + EXPECT_TRUE(plan.steps[0].clearColor); + EXPECT_TRUE(plan.steps[0].clearDepth); + EXPECT_TRUE(plan.steps[0].clearStencil); + for (size_t i = 1; i < plan.steps.size(); ++i) { + EXPECT_EQ(plan.steps[i].pass, i); + EXPECT_FALSE(plan.steps[i].begin); + } +} + +TEST(EyePassPlan, FullClearErasesEverythingDrawnBeforeIt) { + // A copy that clears the whole EFB: the eye never shows what came before it. + const Plan plan = build({efb(true, true), efb(false, false), efb(true, true), efb(false, false)}); + EXPECT_EQ(plan.dead, 2u); + EXPECT_EQ(plan.renderPasses, 1u); + ASSERT_EQ(plan.steps.size(), 2u); + EXPECT_EQ(plan.steps[0].pass, 2u); + EXPECT_TRUE(plan.steps[0].begin); + EXPECT_TRUE(plan.steps[0].clearColor); + EXPECT_TRUE(plan.steps[0].clearDepth); + // The stencil is cleared where the eye actually starts, so a cockpit mask drawn in a pass that + // gets erased can no longer punch holes in the HUD. + EXPECT_TRUE(plan.steps[0].clearStencil); + EXPECT_EQ(plan.steps[1].pass, 3u); + EXPECT_FALSE(plan.steps[1].begin); +} + +TEST(EyePassPlan, ClearOfOnlyColorOrOnlyDepthSplits) { + const Plan plan = build({efb(true, true), efb(false, true), efb(false, false), efb(true, false)}); + EXPECT_EQ(plan.dead, 0u); + EXPECT_EQ(plan.renderPasses, 3u); + EXPECT_EQ(plan.splits, 2u); + ASSERT_EQ(plan.steps.size(), 4u); + EXPECT_TRUE(plan.steps[1].begin); + EXPECT_FALSE(plan.steps[1].clearColor); + EXPECT_TRUE(plan.steps[1].clearDepth); + EXPECT_FALSE(plan.steps[1].clearStencil); + EXPECT_FALSE(plan.steps[2].begin); + EXPECT_TRUE(plan.steps[3].begin); + EXPECT_TRUE(plan.steps[3].clearColor); + EXPECT_FALSE(plan.steps[3].clearDepth); + EXPECT_FALSE(plan.steps[3].clearStencil); +} + +TEST(EyePassPlan, DisplayCopyResetFollowsTheCopyClearSwitch) { + // The continuation after GXCopyDisp holds only that copy's EFB reset. + const std::vector passes{efb(true, true), efb(true, true, false, true)}; + const Plan skipped = build(passes, -1, true); + EXPECT_EQ(skipped.dead, 0u); + EXPECT_EQ(skipped.empty, 1u); + ASSERT_EQ(skipped.steps.size(), 1u); + EXPECT_EQ(skipped.steps[0].pass, 0u); + + // Replayed raw, the reset erases the frame, as the legacy replay's black eye shows. + const Plan raw = build(passes, -1, false); + EXPECT_EQ(raw.dead, 1u); + ASSERT_EQ(raw.steps.size(), 1u); + EXPECT_EQ(raw.steps[0].pass, 1u); + EXPECT_TRUE(raw.steps[0].clearColor); + EXPECT_TRUE(raw.steps[0].clearDepth); +} + +TEST(EyePassPlan, OffscreenPassesNeitherSplitNorEraseTheEye) { + const Plan plan = build({efb(true, true), offscreen(), efb(false, false)}); + EXPECT_EQ(plan.efbPasses, 2u); + EXPECT_EQ(plan.dead, 0u); + EXPECT_EQ(plan.renderPasses, 1u); + ASSERT_EQ(plan.steps.size(), 2u); + EXPECT_EQ(plan.steps[0].pass, 0u); + EXPECT_EQ(plan.steps[1].pass, 2u); + EXPECT_FALSE(plan.steps[1].begin); +} + +TEST(EyePassPlan, StopsAtTheLastReplayedPass) { + const std::vector passes{efb(true, true), efb(false, false), efb(true, true)}; + const Plan cut = build(passes, 1); + EXPECT_EQ(cut.efbPasses, 2u); + EXPECT_EQ(cut.dead, 0u); + ASSERT_EQ(cut.steps.size(), 2u); + EXPECT_EQ(cut.steps[1].pass, 1u); + + const Plan whole = build(passes, -1); + EXPECT_EQ(whole.efbPasses, 3u); + EXPECT_EQ(whole.dead, 2u); + ASSERT_EQ(whole.steps.size(), 1u); + EXPECT_EQ(whole.steps[0].pass, 2u); + + const Plan beyond = build(passes, 10); + EXPECT_EQ(beyond.efbPasses, 3u); +} + +TEST(EyePassPlan, SkipsEmptyPassesButKeepsClears) { + const Plan plan = build({efb(true, true, false), efb(false, false, false), efb(false, false)}); + EXPECT_EQ(plan.empty, 1u); + EXPECT_EQ(plan.renderPasses, 1u); + ASSERT_EQ(plan.steps.size(), 2u); + EXPECT_EQ(plan.steps[0].pass, 0u); + EXPECT_TRUE(plan.steps[0].clearColor); + EXPECT_EQ(plan.steps[1].pass, 2u); + EXPECT_FALSE(plan.steps[1].begin); +} + +TEST(EyePassPlan, AResumedFrameStartsByLoadingTheEye) { + const Plan plan = build({efb(false, false), efb(false, false)}); + EXPECT_EQ(plan.renderPasses, 1u); + ASSERT_EQ(plan.steps.size(), 2u); + EXPECT_TRUE(plan.steps[0].begin); + EXPECT_FALSE(plan.steps[0].clearColor); + EXPECT_FALSE(plan.steps[0].clearDepth); + EXPECT_TRUE(plan.steps[0].clearStencil); + EXPECT_FALSE(plan.steps[1].begin); +} + +TEST(EyePassPlan, NothingToReplay) { + const Plan none = build({offscreen()}); + EXPECT_TRUE(none.steps.empty()); + EXPECT_EQ(none.efbPasses, 0u); + EXPECT_EQ(none.renderPasses, 0u); + EXPECT_EQ(none.splits, 0u); + + const Plan empty = build({}); + EXPECT_TRUE(empty.steps.empty()); +} + +TEST(EyePassPlan, RebuildingReusesThePlan) { + Plan plan; + const std::vector first{efb(true, true), efb(false, true), efb(true, true), efb(false, false)}; + eye_pass_plan::build(first.data(), first.size(), -1, true, plan); + const std::vector second{efb(true, true), efb(false, false)}; + eye_pass_plan::build(second.data(), second.size(), -1, true, plan); + EXPECT_EQ(plan.efbPasses, 2u); + EXPECT_EQ(plan.dead, 0u); + EXPECT_EQ(plan.empty, 0u); + EXPECT_EQ(plan.renderPasses, 1u); + EXPECT_EQ(plan.splits, 0u); + EXPECT_EQ(plan.steps.size(), 2u); +} + +} // namespace +} // namespace aurora::gfx::eye_pass_plan diff --git a/docs/quest-port.md b/docs/quest-port.md index 5c7c62b..9b7ec98 100644 --- a/docs/quest-port.md +++ b/docs/quest-port.md @@ -809,6 +809,7 @@ the app: | `debug.wiicompiled.vtxpad 0` | Turns the stride padding off, to re-check a driver update | | `debug.wiicompiled.validation 1` | Keeps WebGPU validation and robustness on in release builds | | `debug.wiicompiled.panel_layer 0` | Draws the headset settings panel into the eye images instead of on its own quad layer (`OPENXR.md`, Settings in the headset); read about once a second, so it can be switched while the panel is open | +| `debug.wiicompiled.eye_passes 0` | Replays each eye in one render pass per recorded pass, as before `single_pass_eyes` (`OPENXR.md`); `1` forces the single pass and an empty value restores the setting. Read about once a second, for A/B timing inside one session | | `debug.wiicompiled.inject :