mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 05:00:27 +02:00
Replay each VR eye in one render pass
- Added eye_pass_plan.hpp: an eye keeps drawing in the render pass it has open across the frame's
GX copies (only the mono render performs them), skips passes a later clear of the whole color and
depth erases, and splits only for a clear of color alone or depth alone.
- render_stereo_eye follows that plan; the cockpit fallback is drawn inside the last pass instead of
a render pass of its own that loaded the eye back.
- The stencil is now cleared where the eye actually starts, so a cockpit mask drawn in an erased pass
can no longer punch holes in the HUD.
- [vr] single_pass_eyes (default on, live from F10) and debug.wiicompiled.eye_passes 0/1 on the Quest
switch back to one render pass per recorded pass for A/B timing.
- Each new plan shape is logged once ("Eye replay plan: ...").
- Tests: EyePassPlan cases in gx_fifo_tests; stereo_multiplayer_smoke and stereo_frame_worker_smoke
pass on D3D12.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13311ea626
commit
ceeeba0332
12 files changed
+527
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+146
-18
@@ -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<RenderPass>& 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<RenderPass>& 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<float>(target.size.width), static_cast<float>(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<RenderPass>& 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<RenderPass>& 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<RenderPass>& 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<RenderPass>& passes, int32_t lastPass,
|
||||
bool skipCopyClears) {
|
||||
thread_local std::vector<eye_pass_plan::PassSummary> 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<std::array<uint32_t, 5>> loggedShapes;
|
||||
const std::array<uint32_t, 5> 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// 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<Step> 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<size_t>(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<uint32_t>(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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#include "gfx/eye_pass_plan.hpp"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
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<PassSummary>& 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
|
||||
@@ -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 <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes. As a Wii Remote, `x`/`y`/`start` are 1/2/+, the directions push the Nunchuk stick, and `home`, `c` and `z` also exist. `panel` presses the settings panel's button (left Y, or both thumbsticks as a gamepad), opening or closing it (see `OPENXR.md`) |
|
||||
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s, with per-frame averages of the producer's waits for the frame worker's DONE and SEALED phases and of the worker's seal, permit wait, prepare and encode stretches, and of the draw calls the recorded frame holds and the primitives that merged into them (an overlay that stops draws merging shows up there first). A third line reports the GX thread's command ring (records, waits, busy share). A second line gives the GPU time per frame from timestamp queries on every pass (`mono` native render, `eyeL`/`eyeR` replays, `screen`, `panel`, `efbcopy`, `palette`, `peek`, plus `passes-span` from the first pass begin to the last pass end and `between-passes` for copies and idle gaps). The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%`, clock levels and app GPU time (`App=`) |
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ struct RuntimeUserConfig {
|
||||
std::optional<bool> vrPassthrough;
|
||||
std::optional<bool> vrStopAtDisplayCopy;
|
||||
std::optional<bool> vrSkipCopyClears;
|
||||
std::optional<bool> vrSinglePassEyes;
|
||||
std::optional<std::string> vrMirrorView;
|
||||
std::optional<std::string> vrControllerMode;
|
||||
std::optional<uint32_t> vrFrameInterpolationFps;
|
||||
@@ -509,6 +510,10 @@ inline void EnsureConfigFile() {
|
||||
"# from erasing the eye, and both are safe to turn off.\n"
|
||||
"stop_at_display_copy = true\n"
|
||||
"skip_copy_clears = true\n"
|
||||
"# Draw each eye in one render pass across the frame's GX copies,\n"
|
||||
"# which only the desktop image performs: the same picture with\n"
|
||||
"# less GPU memory traffic. Changeable live from the F10 menu.\n"
|
||||
"single_pass_eyes = true\n"
|
||||
"# Put the camera at the Player 1 driver's head instead of behind\n"
|
||||
"# the kart, with the horizon kept level. Changeable live from the\n"
|
||||
"# F10 menu, and only during a single-screen race.\n"
|
||||
@@ -757,6 +762,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
config.vrPassthrough = FindConfigValue<bool>(document, "vr", "passthrough");
|
||||
config.vrStopAtDisplayCopy = FindConfigValue<bool>(document, "vr", "stop_at_display_copy");
|
||||
config.vrSkipCopyClears = FindConfigValue<bool>(document, "vr", "skip_copy_clears");
|
||||
config.vrSinglePassEyes = FindConfigValue<bool>(document, "vr", "single_pass_eyes");
|
||||
config.vrFirstPerson = FindConfigValue<bool>(document, "vr", "first_person");
|
||||
config.vrFirstPersonToggleClick = FindConfigValue<bool>(document, "vr", "first_person_toggle_click");
|
||||
if (auto value = FindConfigFloat(document, "vr", "first_person_units_per_meter");
|
||||
@@ -1088,6 +1094,11 @@ inline bool SetVrSkipCopyClears(bool value) {
|
||||
return WriteSetting("vr", "skip_copy_clears", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrSinglePassEyes(bool value) {
|
||||
Mutable().vrSinglePassEyes = value;
|
||||
return WriteSetting("vr", "single_pass_eyes", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrFirstPerson(bool value) {
|
||||
Mutable().vrFirstPerson = value;
|
||||
return WriteSetting("vr", "first_person", value ? "true" : "false");
|
||||
@@ -1553,6 +1564,10 @@ inline bool VrSkipCopyClears(bool fallback = true) {
|
||||
return Get().vrSkipCopyClears.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrSinglePassEyes(bool fallback = true) {
|
||||
return Get().vrSinglePassEyes.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrFirstPerson(bool fallback = false) {
|
||||
return Get().vrFirstPerson.value_or(fallback);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ bool g_showFps = RuntimeConfigFile::ShowFps();
|
||||
bool g_vrEnabled = RuntimeConfigFile::VrEnabled(true);
|
||||
bool g_vrStopAtDisplayCopy = RuntimeConfigFile::VrStopAtDisplayCopy(true);
|
||||
bool g_vrSkipCopyClears = RuntimeConfigFile::VrSkipCopyClears(true);
|
||||
bool g_vrSinglePassEyes = RuntimeConfigFile::VrSinglePassEyes(true);
|
||||
bool g_vrHudVirtualScreen = RuntimeConfigFile::VrHudVirtualScreen(true);
|
||||
bool g_vrFlatScreen = RuntimeConfigFile::VrFlatScreen();
|
||||
#if defined(__ANDROID__)
|
||||
@@ -1424,6 +1425,18 @@ void DrawVrSettings() {
|
||||
"Both apply on the next frame. Turning either off restores the raw replay and is "
|
||||
"expected to black out the eyes.");
|
||||
ImGui::PopTextWrapPos();
|
||||
// Shows the live state, which the Quest's debug.wiicompiled.eye_passes can override.
|
||||
g_vrSinglePassEyes = aurora_get_stereo_single_pass_eyes();
|
||||
if (ImGui::Checkbox("One render pass per eye", &g_vrSinglePassEyes)) {
|
||||
aurora_set_stereo_single_pass_eyes(g_vrSinglePassEyes);
|
||||
RuntimeConfigFile::SetVrSinglePassEyes(g_vrSinglePassEyes);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip(
|
||||
"Keeps drawing each eye in the render pass it has open across the frame's GX "
|
||||
"copies, which only the desktop image performs, and skips what a later clear "
|
||||
"erases. Same picture, less GPU memory traffic; turn off to compare.");
|
||||
}
|
||||
ImGui::Separator();
|
||||
ImGui::Text("VR 2D layer");
|
||||
ImGui::BeginDisabled(g_vrFlatScreen);
|
||||
@@ -2317,6 +2330,7 @@ void InitializeRuntimeSettings() noexcept {
|
||||
aurora_set_disable_copy_filter(g_disableCopyFilter);
|
||||
aurora_set_stereo_stop_at_display_copy(g_vrStopAtDisplayCopy);
|
||||
aurora_set_stereo_skip_copy_clears(g_vrSkipCopyClears);
|
||||
aurora_set_stereo_single_pass_eyes(g_vrSinglePassEyes);
|
||||
aurora_set_stereo_mirror_view(static_cast<AuroraStereoMirrorView>(g_vrMirrorView));
|
||||
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
|
||||
ApplyVrHudVirtualScreen();
|
||||
|
||||
@@ -850,6 +850,7 @@ private:
|
||||
// not make that layer has the panel drawn into the eyes instead.
|
||||
const bool panel_layer = backend_->PanelLayerAvailable() && !PanelLayerForcedOff();
|
||||
aurora_set_stereo_panel_layer(panel_layer);
|
||||
PollEyePassesOverride();
|
||||
presentation.panel.requested = panel_layer && OpenXRSettingsPanelOpen();
|
||||
|
||||
// Pipeline caches are stored where their stall is least visible: once when a race
|
||||
@@ -1449,6 +1450,37 @@ private:
|
||||
#endif
|
||||
}
|
||||
|
||||
// Android: `adb shell setprop debug.wiicompiled.eye_passes 0` replays each eye in one render
|
||||
// pass per recorded pass again, and 1 forces the single pass, to compare the two within one
|
||||
// session. An empty value hands the switch back to the settings. Read about once a second.
|
||||
void PollEyePassesOverride() noexcept {
|
||||
#if defined(__ANDROID__)
|
||||
if (eye_passes_poll_ != 0) {
|
||||
--eye_passes_poll_;
|
||||
return;
|
||||
}
|
||||
eye_passes_poll_ = 72;
|
||||
char value[PROP_VALUE_MAX] = {};
|
||||
int override_value = -1;
|
||||
if (__system_property_get("debug.wiicompiled.eye_passes", value) > 0 &&
|
||||
(value[0] == '0' || value[0] == '1')) {
|
||||
override_value = value[0] - '0';
|
||||
}
|
||||
if (override_value == eye_passes_override_) {
|
||||
return;
|
||||
}
|
||||
eye_passes_override_ = override_value;
|
||||
const bool single =
|
||||
override_value >= 0 ? override_value == 1 : RuntimeConfigFile::VrSinglePassEyes();
|
||||
aurora_set_stereo_single_pass_eyes(single);
|
||||
RT_LOG(RT_TAG_RUNTIME) << "OpenXR: eyes replayed in "
|
||||
<< (single ? "one render pass" : "one render pass per recorded pass")
|
||||
<< (override_value >= 0 ? " (debug.wiicompiled.eye_passes)"
|
||||
: " (debug.wiicompiled.eye_passes cleared)")
|
||||
<< std::endl;
|
||||
#endif
|
||||
}
|
||||
|
||||
// The settings panel's layer hangs exactly where its pointer hits are
|
||||
// tested, the rectangle it used to cover in the eyes.
|
||||
static void PlacePanelLayer(OpenXRBackendFrame& frame, const OpenXRPointerScreen& screen) noexcept {
|
||||
@@ -1672,6 +1704,8 @@ private:
|
||||
#if defined(__ANDROID__)
|
||||
uint32_t panel_layer_poll_ = 0;
|
||||
bool panel_layer_forced_off_ = false;
|
||||
uint32_t eye_passes_poll_ = 0;
|
||||
int eye_passes_override_ = -1;
|
||||
#endif
|
||||
std::unique_ptr<OpenXRInput> input_;
|
||||
std::thread pacing_thread_;
|
||||
|
||||
Reference in new issue
Block a user