Added Support for Multiplayer Player 1 Immersive View

This commit is contained in:
iChris4 committed 2026-09-16 17:06:03 +02:00
1 parent 9e37bb6447
commit 3e8bdcfd7b
22 files changed
+1013 -38

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.32</Version> <Version>0.2.38</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+31
View File
@@ -66,6 +66,30 @@ image is chosen, so the setting can always be changed back.
Eye mirror modes retain the last eye image when a desktop frame has no new XR packet, so they Eye mirror modes retain the last eye image when a desktop frame has no new XR packet, so they
do not alternate with the normal camera. `"none"` also stays black between XR packets. do not alternate with the normal camera. `"none"` also stays black between XR packets.
## Local multiplayer
During 2-, 3-, and 4-player races, the headset replays Player 1's world in immersive stereo
with head tracking. Keep **F10 > VR > Desktop view** set to **Normal** (`mirror_view = "normal"`)
for the original desktop split-screen layout. No extra multiplayer switch is required.
Menus continue to use the virtual screen.
Only headset replay filters the other players' viewports and expands Player 1 to each eye.
The desktop split-screen partition (the game's `partition_line` layout, one-pixel textured
picture panes on the split boundaries) and full masks of the other panes are omitted from
the eyes; the desktop image keeps them.
Player-local HUD viewports follow Player 1; shared orthographic overlays keep their full-screen
layout on the virtual screen. Framebuffer effects that sample the desktop split-screen image
are omitted from multiplayer eyes, since those textures contain the other cameras too.
The local-screen count is sealed with each frame, including retained VR interpolation frames,
and a layout change invalidates older XR packets.
Multiplayer uses Player 1's game camera. The optional first-person relocation and model hiding
remain single-player-only: guest model visibility changes would also affect the desktop players.
The opt-in `stereo_multiplayer_smoke` D3D12 test reads back both eye images and the desktop EFB
for 1/2/3/4/1-screen transitions with VR interpolation on and off. Actual headset racing still
needs visual validation for course effects, HUD layout, pause/resume, and scene transitions.
**F10 > VR > VR frame interpolation (experimental)** offers **Off, Auto, 72, 90, 120** and **F10 > VR > VR frame interpolation (experimental)** offers **Off, Auto, 72, 90, 120** and
applies immediately. `frame_interpolation_fps` stores `0` for Off (the default), `1` for Auto, applies immediately. `frame_interpolation_fps` stores `0` for Off (the default), `1` for Auto,
or the selected rate. The earlier `frame_interpolation = true` checkbox migrates to Auto. or the selected rate. The earlier `frame_interpolation = true` checkbox migrates to Auto.
@@ -240,6 +264,13 @@ so it can be enabled once Aurora exposes those handles safely.
### Interpolation validation ### Interpolation validation
Probe-sized EFB readbacks retain completed pixels in host memory and publish them only inside
the next compatible `GXCopyTex` call. GPU completion callbacks must not write to guest RAM:
a race restart can reuse a freed probe buffer for `RaceCamera`, and a late 4x4 Z24X8 tile then
turns its rotation fields into NaNs and triggers `triangular.h` / `PPCHalt`.
The optional Windows GPU test `efb_ram_lifetime_smoke` exercises that allocation reuse and
format/size changes. It fails with the former callback write and passes with deferred publication.
The GX tests cover retained transform endpoints with desktop interpolation off and continuous The GX tests cover retained transform endpoints with desktop interpolation off and continuous
sampling at 72/90/120 Hz. `mkw_frame_interpolation_pacing_tests` covers fixed-rate scheduling, sampling at 72/90/120 Hz. `mkw_frame_interpolation_pacing_tests` covers fixed-rate scheduling,
live changes, stalls and configuration migration; `mkw_openxr_replay_tests` exercises swapchain live changes, stalls and configuration migration; `mkw_openxr_replay_tests` exercises swapchain
+4
View File
@@ -236,6 +236,10 @@ void aurora_end_frame_tagged(uint64_t contentTag);
* provider, which cannot know which frame will consume its packet. * provider, which cannot know which frame will consume its packet.
*/ */
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]); void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]);
// Select Player 1's subview for immersive replay of 2-4 local screens.
// Producer-thread, per-frame metadata, consumed by the next end_frame call.
// One (the default) keeps full-frame replay. Desktop rendering is unaffected.
void aurora_set_stereo_local_player_count(uint32_t count);
typedef void (*AuroraFrameWorkerWaitCallback)(); typedef void (*AuroraFrameWorkerWaitCallback)();
// Called from the producer thread at bounded intervals while Aurora waits for // Called from the producer thread at bounded intervals while Aurora waits for
// the asynchronous frame worker. The callback must not enter Aurora. // the asynchronous frame worker. The callback must not enter Aurora.
+9 -1
View File
@@ -100,11 +100,13 @@ struct StereoSceneAnchor {
0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f, 0.f,
}; };
bool active = false; bool active = false;
uint32_t localPlayerCount = 1;
}; };
// Producer thread only, between aurora_set_stereo_scene_anchor() and the seal // Producer thread only, between aurora_set_stereo_scene_anchor() and the seal
// that consumes it. Cleared at every seal so a producer that stops publishing // that consumes it. Cleared at every seal so a producer that stops publishing
// falls back to the recorded camera instead of freezing on a stale anchor. // falls back to the recorded camera instead of freezing on a stale anchor.
StereoSceneAnchor g_pendingSceneAnchor; StereoSceneAnchor g_pendingSceneAnchor;
uint32_t g_pendingStereoLocalPlayerCount = 1;
using PresentClock = std::chrono::steady_clock; using PresentClock = std::chrono::steady_clock;
@@ -1762,6 +1764,7 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
// current_frame() advances inside gfx::end_frame; unsigned wrap maps the // current_frame() advances inside gfx::end_frame; unsigned wrap maps the
// pre-first-frame UINT32_MAX value to logical frame zero. // pre-first-frame UINT32_MAX value to logical frame zero.
ctx.logicalFrame = gfx::current_frame() + 1; ctx.logicalFrame = gfx::current_frame() + 1;
gfx::set_stereo_local_player_count(sceneAnchor.localPlayerCount);
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) { if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
ctx.stereoInput = stereoInput; ctx.stereoInput = stereoInput;
ctx.stereoFrameToken = stereoInput->frameToken; ctx.stereoFrameToken = stereoInput->frameToken;
@@ -2246,7 +2249,9 @@ void end_frame(uint64_t contentTag) noexcept {
#endif #endif
// Claim the anchor published for this frame. Clearing it here is what makes a // Claim the anchor published for this frame. Clearing it here is what makes a
// producer that stops publishing fall back to the recorded camera. // producer that stops publishing fall back to the recorded camera.
const StereoSceneAnchor sceneAnchor = g_pendingSceneAnchor; StereoSceneAnchor sceneAnchor = g_pendingSceneAnchor;
sceneAnchor.localPlayerCount = g_pendingStereoLocalPlayerCount;
g_pendingStereoLocalPlayerCount = 1;
g_pendingSceneAnchor = {}; g_pendingSceneAnchor = {};
if (!frame_worker_requested()) { if (!frame_worker_requested()) {
end_frame_impl(true, true, contentTag, sceneAnchor); end_frame_impl(true, true, contentTag, sceneAnchor);
@@ -2378,6 +2383,9 @@ void aurora_end_frame_tagged(uint64_t contentTag) { aurora::end_frame(contentTag
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]) { void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]) {
aurora::set_stereo_scene_anchor(anchorFromScene); aurora::set_stereo_scene_anchor(anchorFromScene);
} }
void aurora_set_stereo_local_player_count(uint32_t count) {
aurora::g_pendingStereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1;
}
void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) { void aurora_set_frame_worker_wait_callback(AuroraFrameWorkerWaitCallback callback) {
aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release); aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release);
} }
+79 -9
View File
@@ -305,6 +305,7 @@ static void recycle_render_passes(std::vector<RenderPass>& passes) noexcept {
struct LateStereoUniform { struct LateStereoUniform {
gx::UniformReplayLayout layout; gx::UniformReplayLayout layout;
Viewport viewport; Viewport viewport;
ClipRect displayRegion;
Range current; Range current;
Range previous; Range previous;
std::array<Range, AURORA_STEREO_EYE_COUNT> eyes; std::array<Range, AURORA_STEREO_EYE_COUNT> eyes;
@@ -322,9 +323,16 @@ struct LateStereoData {
// Advanced for every upload, including synchronous mid-frame EFB readbacks. // Advanced for every upload, including synchronous mid-frame EFB readbacks.
static std::atomic_uint64_t g_replayBufferGeneration{0}; static std::atomic_uint64_t g_replayBufferGeneration{0};
static LateStereoData g_pendingLateStereo; static LateStereoData g_pendingLateStereo;
static uint32_t g_stereoLocalPlayerCount = 1;
void set_stereo_local_player_count(uint32_t count) noexcept {
g_stereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1;
}
struct SealedFrameData { struct SealedFrameData {
std::vector<RenderPass> passes; std::vector<RenderPass> passes;
LateStereoData stereo; LateStereoData stereo;
uint32_t localPlayerCount = 1;
}; };
SealedFrame::SealedFrame() : m_data(std::make_unique<SealedFrameData>()) {} SealedFrame::SealedFrame() : m_data(std::make_unique<SealedFrameData>()) {}
@@ -1396,6 +1404,10 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
LateStereoData* history = nullptr) noexcept { LateStereoData* history = nullptr) noexcept {
const StereoDisplaySource displaySource = stereo_display_source(g_renderPasses); const StereoDisplaySource displaySource = stereo_display_source(g_renderPasses);
const ClipRect displayRegion = displaySource.region; const ClipRect displayRegion = displaySource.region;
const bool multiplayer = g_stereoLocalPlayerCount > 1;
const auto playerRegion = stereo_replay::player_one_region(
{float(displayRegion.x), float(displayRegion.y), float(displayRegion.width), float(displayRegion.height)},
g_stereoLocalPlayerCount);
// This is the producer-side preparation path; eye replay can query the pure // This is the producer-side preparation path; eye replay can query the pure
// helper concurrently without touching this diagnostic state. // helper concurrently without touching this diagnostic state.
log_stereo_display_source_region(displayRegion, displaySource.foundDisplayCopy); log_stereo_display_source_region(displayRegion, displaySource.foundDisplayCopy);
@@ -1403,18 +1415,25 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
// A draw is replayed per eye when it carries the game camera (perspective) or // A draw is replayed per eye when it carries the game camera (perspective) or
// when it is 2D content the virtual screen is claiming. // when it is 2D content the virtual screen is claiming.
const auto replayed = [&](const gx::UniformReplayLayout& layout) noexcept { const auto replayed = [&](const gx::UniformReplayLayout& layout) noexcept {
return layout.perspective || (hudScreen.valid() && !layout.nativeEfbEffect); return (!multiplayer || !layout.nativeEfbEffect) &&
(layout.perspective || (hudScreen.valid() && !layout.nativeEfbEffect));
}; };
size_t requiredBytes = 0; size_t requiredBytes = 0;
size_t efbPassCount = 0; size_t efbPassCount = 0;
size_t perspectiveDrawCount = 0; size_t perspectiveDrawCount = 0;
size_t replayPerspectiveDrawCount = 0; size_t replayPerspectiveDrawCount = 0;
size_t replayHudScreenDrawCount = 0; size_t replayHudScreenDrawCount = 0;
stereo_replay::SubviewRect allocationViewport{float(displayRegion.x), float(displayRegion.y),
float(displayRegion.width), float(displayRegion.height)};
for (const auto& pass : g_renderPasses) { for (const auto& pass : g_renderPasses) {
if (pass.efbTarget) { if (pass.efbTarget) {
++efbPassCount; ++efbPassCount;
} }
for (const auto& command : pass.commands) { for (const auto& command : pass.commands) {
if (pass.efbTarget && command.type == CommandType::SetViewport) {
const auto& vp = command.data.setViewport;
allocationViewport = {vp.left, vp.top, vp.width, vp.height};
}
if (command.type != CommandType::Draw || command.data.draw.type != ShaderType::GX || if (command.type != CommandType::Draw || command.data.draw.type != ShaderType::GX ||
!replayed(command.data.draw.gx.uniformReplayLayout)) { !replayed(command.data.draw.gx.uniformReplayLayout)) {
continue; continue;
@@ -1427,6 +1446,10 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
if (!pass.efbTarget) { if (!pass.efbTarget) {
continue; continue;
} }
if (multiplayer && !stereo_replay::replay_player_one_draw(allocationViewport, playerRegion, layout.perspective,
layout.nativeEfbEffect)) {
continue;
}
if (layout.perspective) { if (layout.perspective) {
++replayPerspectiveDrawCount; ++replayPerspectiveDrawCount;
} else { } else {
@@ -1499,6 +1522,18 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
} }
auto& draw = command.data.draw.gx; auto& draw = command.data.draw.gx;
const auto& layout = draw.uniformReplayLayout; const auto& layout = draw.uniformReplayLayout;
const stereo_replay::SubviewRect viewportRect{drawViewport.left, drawViewport.top, drawViewport.width,
drawViewport.height};
if (multiplayer && !stereo_replay::replay_player_one_draw(viewportRect, playerRegion, layout.perspective,
layout.nativeEfbEffect)) {
continue;
}
// Pane-local HUD coordinates expand with P1. Shared race/pause overlays
// retain their full-screen layout on the virtual screen.
const bool playerLocal = multiplayer && stereo_replay::subview_contains(playerRegion, viewportRect);
const ClipRect uniformRegion = playerLocal ? ClipRect{int32_t(playerRegion.left), int32_t(playerRegion.top),
int32_t(playerRegion.width), int32_t(playerRegion.height)}
: displayRegion;
std::memcpy(sourceUniform.data(), g_uniforms.data() + draw.uniformRange.offset, draw.uniformRange.size); std::memcpy(sourceUniform.data(), g_uniforms.data() + draw.uniformRange.offset, draw.uniformRange.size);
Mat4x4<float> gameProjection; Mat4x4<float> gameProjection;
std::memcpy(&gameProjection, sourceUniform.data() + layout.projectionOffset, sizeof(gameProjection)); std::memcpy(&gameProjection, sourceUniform.data() + layout.projectionOffset, sizeof(gameProjection));
@@ -1522,6 +1557,7 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
saved = &history->uniforms.emplace_back(); saved = &history->uniforms.emplace_back();
saved->layout = layout; saved->layout = layout;
saved->viewport = drawViewport; saved->viewport = drawViewport;
saved->displayRegion = uniformRegion;
const auto save = [&](const uint8_t* source, uint32_t size) -> Range { const auto save = [&](const uint8_t* source, uint32_t size) -> Range {
if (size == 0) if (size == 0)
return {}; return {};
@@ -1544,7 +1580,7 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
} }
const auto& eye = stereoFrame.eyes[eyeIndex]; const auto& eye = stereoFrame.eyes[eyeIndex];
std::memcpy(eyeUniform.data(), sourceUniform.data(), range.size); std::memcpy(eyeUniform.data(), sourceUniform.data(), range.size);
write_stereo_uniform({eyeUniform.data(), range.size}, layout, eye, gameProjection, drawViewport, displayRegion, write_stereo_uniform({eyeUniform.data(), range.size}, layout, eye, gameProjection, drawViewport, uniformRegion,
hudScreen); hudScreen);
std::memcpy(uniform.data(), eyeUniform.data(), range.size); std::memcpy(uniform.data(), eyeUniform.data(), range.size);
} }
@@ -1681,6 +1717,7 @@ struct RenderInvocation {
uint32_t stereoEye = UINT32_MAX; uint32_t stereoEye = UINT32_MAX;
const ReplayTarget* target = nullptr; const ReplayTarget* target = nullptr;
ClipRect replaySourceRegion{}; ClipRect replaySourceRegion{};
uint32_t localPlayerCount = 1;
// Inclusive index of the last pass to replay; -1 replays every pass. // Inclusive index of the last pass to replay; -1 replays every pass.
int32_t replayLastPass = -1; int32_t replayLastPass = -1;
bool finalize = true; bool finalize = true;
@@ -1857,6 +1894,8 @@ void seal_frame(SealedFrame& out) noexcept {
// producer joins the worker's DONE phase before it seals another frame. // producer joins the worker's DONE phase before it seals another frame.
g_retiredBindGroups.clear(); g_retiredBindGroups.clear();
out.data().stereo = std::move(g_pendingLateStereo); out.data().stereo = std::move(g_pendingLateStereo);
out.data().localPlayerCount = g_stereoLocalPlayerCount;
g_stereoLocalPlayerCount = 1;
auto& passes = out.data().passes; auto& passes = out.data().passes;
// The previous cycle already recycled these, so this normally just hands the empty vector, its // The previous cycle already recycled these, so this normally just hands the empty vector, its
// capacity included, back to the producer. // capacity included, back to the producer.
@@ -1928,7 +1967,7 @@ bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, c
} }
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) { for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
write_stereo_uniform({bytes + saved.eyes[eye].offset - data.uploadOffset, saved.current.size}, layout, write_stereo_uniform({bytes + saved.eyes[eye].offset - data.uploadOffset, saved.current.size}, layout,
stereoFrame.eyes[eye], projection, saved.viewport, data.displayRegion, data.hudScreen); stereoFrame.eyes[eye], projection, saved.viewport, saved.displayRegion, data.hudScreen);
} }
} }
// Never interpolate in mapped upload memory: write-combined pages make CPU // Never interpolate in mapped upload memory: write-combined pages make CPU
@@ -1961,6 +2000,7 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster
.stereoEye = eye, .stereoEye = eye,
.target = &stereoFrame.eyes[eye].target, .target = &stereoFrame.eyes[eye].target,
.replaySourceRegion = displaySource.region, .replaySourceRegion = displaySource.region,
.localPlayerCount = frame.data().localPlayerCount,
.replayLastPass = lastPass, .replayLastPass = lastPass,
.finalize = finalize, .finalize = finalize,
.replayOnlyEfb = true, .replayOnlyEfb = true,
@@ -2010,6 +2050,12 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
const auto& sourceSize = renderPasses[idx].targetSize; const auto& sourceSize = renderPasses[idx].targetSize;
const bool overrideTarget = invocation.target != nullptr && renderPasses[idx].efbTarget; const bool overrideTarget = invocation.target != nullptr && renderPasses[idx].efbTarget;
const auto targetSize = overrideTarget ? invocation.target->size : sourceSize; const auto targetSize = overrideTarget ? invocation.target->size : sourceSize;
const bool multiplayer = overrideTarget && invocation.localPlayerCount > 1;
const auto& display = invocation.replaySourceRegion;
const auto playerRegion = stereo_replay::player_one_region(
{float(display.x), float(display.y), float(display.width), float(display.height)}, invocation.localPlayerCount);
stereo_replay::SubviewRect sourceViewport{0.f, 0.f, float(sourceSize.width), float(sourceSize.height)};
auto sourceScissor = sourceViewport;
const int32_t sourceWidth = static_cast<int32_t>(sourceSize.width); const int32_t sourceWidth = static_cast<int32_t>(sourceSize.width);
const int32_t sourceHeight = static_cast<int32_t>(sourceSize.height); const int32_t sourceHeight = static_cast<int32_t>(sourceSize.height);
int32_t sourceRegionLeft = 0; int32_t sourceRegionLeft = 0;
@@ -2086,6 +2132,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
switch (cmd.type) { switch (cmd.type) {
case CommandType::SetViewport: { case CommandType::SetViewport: {
const auto& vp = cmd.data.setViewport; const auto& vp = cmd.data.setViewport;
sourceViewport = {vp.left, vp.top, vp.width, vp.height};
// WebGPU requires 0 <= minDepth <= maxDepth <= 1. vp.znear/vp.zfar are in GX's own distance // WebGPU requires 0 <= minDepth <= maxDepth <= 1. vp.znear/vp.zfar are in GX's own distance
// terms (0 = near); under UseReversedZ the host depth-buffer storage direction is flipped // terms (0 = near); under UseReversedZ the host depth-buffer storage direction is flipped
// (near = 1, far = 0), so this range has to be remapped through 1-x the same way the // (near = 1, far = 0), so this range has to be remapped through 1-x the same way the
@@ -2121,6 +2168,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
} break; } break;
case CommandType::SetScissor: { case CommandType::SetScissor: {
const auto& sc = cmd.data.setScissor; const auto& sc = cmd.data.setScissor;
sourceScissor = {float(sc.x), float(sc.y), float(sc.width), float(sc.height)};
const auto sourceLeft = std::clamp(sc.x, sourceRegionLeft, sourceRegionRight); const auto sourceLeft = std::clamp(sc.x, sourceRegionLeft, sourceRegionRight);
const auto sourceTop = std::clamp(sc.y, sourceRegionTop, sourceRegionBottom); const auto sourceTop = std::clamp(sc.y, sourceRegionTop, sourceRegionBottom);
const auto sourceRight = std::clamp(sc.x + sc.width, sourceLeft, sourceRegionRight); const auto sourceRight = std::clamp(sc.x + sc.width, sourceLeft, sourceRegionRight);
@@ -2146,6 +2194,18 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
const auto& draw = cmd.data.draw; const auto& draw = cmd.data.draw;
switch (draw.type) { switch (draw.type) {
case ShaderType::GX: { case ShaderType::GX: {
if (multiplayer && draw.gx.screenRect &&
stereo_replay::is_split_screen_furniture(
*draw.gx.screenRect, {float(display.x), float(display.y), float(display.width), float(display.height)},
invocation.localPlayerCount)) {
break;
}
if (multiplayer && (!stereo_replay::replay_player_one_draw(sourceViewport, playerRegion,
draw.gx.uniformReplayLayout.perspective,
draw.gx.uniformReplayLayout.nativeEfbEffect) ||
!stereo_replay::subviews_overlap(sourceScissor, playerRegion))) {
break;
}
const gfx::Range* uniformOverride = nullptr; const gfx::Range* uniformOverride = nullptr;
// Only a 2D draw the virtual screen actually claimed carries a stereo // Only a 2D draw the virtual screen actually claimed carries a stereo
// uniform range without being perspective. // uniform range without being perspective.
@@ -2164,21 +2224,31 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
// frame (Mario Kart clips the item roulette that way). Honouring that // frame (Mario Kart clips the item roulette that way). Honouring that
// rectangle would cut the reprojected element away, so it gets the whole // rectangle would cut the reprojected element away, so it gets the whole
// eye and every other draw gets the game's own rectangle back. // eye and every other draw gets the game's own rectangle back.
if (!scissorStateKnown || virtualScreenDraw != hudScreenScissor) { const bool fullEyeDraw = virtualScreenDraw || (multiplayer && draw.gx.uniformReplayLayout.perspective);
hudScreenScissor = virtualScreenDraw; if (!scissorStateKnown || fullEyeDraw != hudScreenScissor) {
hudScreenScissor = fullEyeDraw;
scissorStateKnown = true; scissorStateKnown = true;
apply_scissor(virtualScreenDraw ? fullTargetScissor : recordedScissor); apply_scissor(fullEyeDraw ? fullTargetScissor : recordedScissor);
} }
if (!viewportStateKnown || virtualScreenDraw != hudScreenViewport) { if (!viewportStateKnown || fullEyeDraw != hudScreenViewport) {
hudScreenViewport = virtualScreenDraw; hudScreenViewport = fullEyeDraw;
viewportStateKnown = true; viewportStateKnown = true;
apply_viewport(virtualScreenDraw); apply_viewport(fullEyeDraw);
} }
gx::render(draw.gx, pass, encodeState, renderPasses[idx].requireReadyPipelines, uniformOverride, gx::render(draw.gx, pass, encodeState, renderPasses[idx].requireReadyPipelines, uniformOverride,
virtualScreenDraw ? draw.gx.exactScreenDepthPipeline : 0); virtualScreenDraw ? draw.gx.exactScreenDepthPipeline : 0);
} break; } break;
case ShaderType::Clear: { case ShaderType::Clear: {
auto clearDraw = draw.clear; auto clearDraw = draw.clear;
if (multiplayer) {
const auto& sc = clearDraw.scissor;
if (clearDraw.copyClear ||
(clearDraw.useScissor &&
!stereo_replay::subviews_overlap({float(sc.x), float(sc.y), float(sc.width), float(sc.height)},
playerRegion))) {
break;
}
}
if (invocation.skipCopyClears && overrideTarget && clearDraw.copyClear && renderPasses[idx].postCopyClear) { if (invocation.skipCopyClears && overrideTarget && clearDraw.copyClear && renderPasses[idx].postCopyClear) {
// The scissored twin of the attachment-load-op case above: the copy's // The scissored twin of the attachment-load-op case above: the copy's
// EFB reset, rescaled into eye space, covers the whole eye. // EFB reset, rescaled into eye space, covers the whole eye.
+2
View File
@@ -316,6 +316,8 @@ struct StereoReplayFrame {
}; };
void end_frame(const wgpu::CommandEncoder& cmd); void end_frame(const wgpu::CommandEncoder& cmd);
// Set under the renderer mutex immediately before preparing/sealing the frame.
void set_stereo_local_player_count(uint32_t count) noexcept;
// Prepares eye-specific uniform copies before unmapping the staging buffer. // Prepares eye-specific uniform copies before unmapping the staging buffer.
// Returns false without modifying the mono path when the uniform buffer has // Returns false without modifying the mono path when the uniform buffer has
// insufficient room for the additional copies. // insufficient room for the additional copies.
+30 -13
View File
@@ -60,7 +60,6 @@ struct AsyncSlot {
uint64_t bufferSize = 0; uint64_t bufferSize = 0;
uint32_t bytesPerRow = 0; uint32_t bytesPerRow = 0;
// Latched at encode time and read by the map callback. // Latched at encode time and read by the map callback.
void* dest = nullptr;
GXTexFmt format = GX_TF_RGBA8; GXTexFmt format = GX_TF_RGBA8;
uint32_t width = 0; uint32_t width = 0;
uint32_t height = 0; uint32_t height = 0;
@@ -68,6 +67,14 @@ struct AsyncSlot {
uint32_t hostHeight = 0; uint32_t hostHeight = 0;
HostPixelOrder order = HostPixelOrder::RGBA; HostPixelOrder order = HostPixelOrder::RGBA;
AsyncState state = AsyncState::Idle; AsyncState state = AsyncState::Idle;
// A guest address is not an allocation lifetime: a scene restart can reuse a
// probe buffer for a camera before this slot's GPU work completes. Callbacks
// therefore retain results here; only a new schedule() may publish to RAM.
std::array<uint8_t, kAsyncReadbackMaxBytes> completedData{};
GXTexFmt completedFormat = GX_TF_RGBA8;
uint32_t completedWidth = 0;
uint32_t completedHeight = 0;
size_t completedSize = 0;
}; };
std::vector<PendingCopy> g_pending; std::vector<PendingCopy> g_pending;
@@ -138,17 +145,18 @@ void complete_async_slot(void* dest, wgpu::MapAsyncStatus status, wgpu::StringVi
if (status == wgpu::MapAsyncStatus::Success) { if (status == wgpu::MapAsyncStatus::Success) {
const auto* pixels = static_cast<const uint8_t*>(slot.buffer.GetConstMappedRange(0, slot.bufferSize)); const auto* pixels = static_cast<const uint8_t*>(slot.buffer.GetConstMappedRange(0, slot.bufferSize));
if (pixels != nullptr) { if (pixels != nullptr) {
// Writes guest RAM from the event-queue thread while the guest may be reading it. The only
// consumer min/maxes depth for a fade factor, so a torn tile just mixes two frames' depths.
const size_t outputSize = encoded_size(slot.format, slot.width, slot.height); const size_t outputSize = encoded_size(slot.format, slot.width, slot.height);
if (!encode(slot.dest, outputSize, slot.format, slot.width, slot.height, pixels, slot.hostWidth, slot.hostHeight, if (!encode(slot.completedData.data(), slot.completedData.size(), slot.format, slot.width, slot.height, pixels,
slot.bytesPerRow, slot.order)) { slot.hostWidth, slot.hostHeight, slot.bytesPerRow, slot.order)) {
slot.completedSize = 0;
Log.error("Failed to encode async EFB RAM copy format=0x{:x} size={}x{}", static_cast<unsigned>(slot.format), Log.error("Failed to encode async EFB RAM copy format=0x{:x} size={}x{}", static_cast<unsigned>(slot.format),
slot.width, slot.height); slot.width, slot.height);
} else {
slot.completedFormat = slot.format;
slot.completedWidth = slot.width;
slot.completedHeight = slot.height;
slot.completedSize = outputSize;
} }
// Guest RAM written from outside the embedder, so nothing bumps its write generation and a
// texture cached over this range would keep its digest.
notify_guest_write(slot.dest, outputSize);
} }
slot.buffer.Unmap(); slot.buffer.Unmap();
} else if (status != wgpu::MapAsyncStatus::CallbackCancelled && status != wgpu::MapAsyncStatus::Aborted) { } else if (status != wgpu::MapAsyncStatus::CallbackCancelled && status != wgpu::MapAsyncStatus::Aborted) {
@@ -201,12 +209,22 @@ void schedule(void* dest, uint32_t width, uint32_t height, GXTexFmt format, Text
async = false; async = false;
} else { } else {
g_asyncSlots.try_emplace(dest); g_asyncSlots.try_emplace(dest);
// No readback has landed here yet. 0xff decodes to far Z, which the probe reads as unobstructed
// so a new flare fades in; zero-filled RAM would decode as fully occluded.
std::memset(dest, 0xff, encodedSize);
notify_guest_write(dest, encodedSize);
} }
} }
if (async) {
const auto& slot = g_asyncSlots.at(dest);
// schedule runs on the producer inside GXCopyTex, while this destination
// is owned by the copy. Never let a later GPU callback write to it.
if (slot.completedSize == encodedSize && slot.completedFormat == format && slot.completedWidth == width &&
slot.completedHeight == height) {
std::memcpy(dest, slot.completedData.data(), encodedSize);
} else {
// No compatible result yet. Far Z makes a new flare fade in instead
// of treating uninitialized RAM as a fully occluded probe.
std::memset(dest, 0xff, encodedSize);
}
notify_guest_write(dest, encodedSize);
}
} }
auto& list = async ? g_asyncPending : g_pending; auto& list = async ? g_asyncPending : g_pending;
@@ -395,7 +413,6 @@ void encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept {
}; };
encoder.CopyTextureToBuffer(&source, &destination, &texture->size); encoder.CopyTextureToBuffer(&source, &destination, &texture->size);
slot.bytesPerRow = bytesPerRow; slot.bytesPerRow = bytesPerRow;
slot.dest = pending.dest;
slot.format = pending.format; slot.format = pending.format;
slot.width = pending.width; slot.width = pending.width;
slot.height = pending.height; slot.height = pending.height;
+4 -1
View File
@@ -15,7 +15,10 @@ bool complete_downloads() noexcept;
void cancel() noexcept; void cancel() noexcept;
// Frame-latent readbacks for probe-sized CPU-consumed copies: they ride the frame's own encode and // Frame-latent readbacks for probe-sized CPU-consumed copies: they ride the frame's own encode and
// land in guest RAM a frame later. Per frame, from the worker: seal, encode, then after_submit. // retain completed pixels in host memory. The producer publishes a compatible
// result only when schedule() is called again for that destination, while the
// game still owns it as a copy buffer. Per frame, from the worker: seal, encode,
// then after_submit. GPU callbacks must never write to guest RAM.
void seal_async_downloads() noexcept; void seal_async_downloads() noexcept;
void encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept; void encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept;
void after_submit() noexcept; void after_submit() noexcept;
+68
View File
@@ -1,9 +1,77 @@
#pragma once #pragma once
#include <aurora/math.hpp> #include <aurora/math.hpp>
#include <cmath>
namespace aurora::gfx::stereo_replay { namespace aurora::gfx::stereo_replay {
struct SubviewRect {
float left = 0.f;
float top = 0.f;
float width = 0.f;
float height = 0.f;
};
// MKW uses a top/bottom split for two screens and quadrants for three/four.
// Coordinates belong to the displayed EFB region, including its crop origin.
inline SubviewRect player_one_region(SubviewRect display, uint32_t players) noexcept {
if (players >= 2 && players <= 4) {
display.height *= 0.5f;
if (players >= 3) {
display.width *= 0.5f;
}
}
return display;
}
inline bool subviews_overlap(SubviewRect a, SubviewRect b) noexcept {
return a.width > 0.f && a.height > 0.f && b.width > 0.f && b.height > 0.f && a.left < b.left + b.width &&
a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top;
}
inline bool subview_contains(SubviewRect outer, SubviewRect inner) noexcept {
// GX viewport jitter and rounding can extend a pane by a fraction of a pixel.
constexpr float tolerance = 1.f;
return inner.width > 0.f && inner.height > 0.f && inner.left >= outer.left - tolerance &&
inner.top >= outer.top - tolerance && inner.left + inner.width <= outer.left + outer.width + tolerance &&
inner.top + inner.height <= outer.top + outer.height + tolerance;
}
// Shared orthographic overlays may span the display. World geometry must belong
// wholly to P1; otherwise another camera could be expanded into the same eye.
// EFB effects sample the desktop's multi-camera image, so they cannot be reused.
inline bool replay_player_one_draw(SubviewRect viewport, SubviewRect playerRegion, bool perspective,
bool nativeEfbEffect) noexcept {
return !nativeEfbEffect &&
(perspective ? subview_contains(playerRegion, viewport) : subviews_overlap(playerRegion, viewport));
}
// Split-screen furniture is often geometry in a full-display orthographic
// viewport, not a separate viewport. MKW draws its partition as the
// partition_line layout: yoko_line (800x1) and tate_line (1x800) picture panes
// centred on the display and sampling a pattern texture. Recognize only
// rectangles/lines at the split boundaries and complete masks of other
// players' panes, whether textured or not. Full-frame fades and small HUD
// backgrounds must remain visible.
inline bool is_split_screen_furniture(SubviewRect bounds, SubviewRect display, uint32_t players) noexcept {
if (players < 2 || players > 4 || display.width <= 0.f || display.height <= 0.f)
return false;
const float x = (bounds.left - display.left) / display.width;
const float y = (bounds.top - display.top) / display.height;
const float w = bounds.width / display.width;
const float h = bounds.height / display.height;
constexpr float tolerance = 0.008f; // Up to a few native EFB pixels of inset/jitter.
const auto near = [](float a, float b) { return std::abs(a - b) <= tolerance; };
if (h <= tolerance && w >= 0.45f && near(y + h * 0.5f, 0.5f))
return true;
if (players >= 3 && w <= tolerance && h >= 0.45f && near(x + w * 0.5f, 0.5f))
return true;
if (players == 2)
return near(x, 0.f) && near(y, 0.5f) && near(w, 1.f) && near(h, 0.5f);
return near(w, 0.5f) && near(h, 0.5f) &&
((near(x, 0.5f) && (near(y, 0.f) || near(y, 0.5f))) || (near(x, 0.f) && near(y, 0.5f)));
}
// An OpenXR eye supplies the shape of its asymmetric frustum, but the sealed // An OpenXR eye supplies the shape of its asymmetric frustum, but the sealed
// GX draw already contains the depth mapping adjusted for that draw's GX // GX draw already contains the depth mapping adjusted for that draw's GX
// viewport and Aurora's reversed-Z convention. Replacing the complete matrix // viewport and Aurora's reversed-Z convention. Replacing the complete matrix
+104 -5
View File
@@ -1861,7 +1861,7 @@ static u32 calculate_last_vtx_size(GXVtxFmt fmt) {
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange, static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature, uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
bool interpolationIdentityActive); bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride);
// The per-draw geometry signature, matrix-usage mask and draw-identity hashes exist purely to feed frame interpolation // The per-draw geometry signature, matrix-usage mask and draw-identity hashes exist purely to feed frame interpolation
// (build_uniform consumes them only after its `frame_interpolation_fps() == 0` early-out). // (build_uniform consumes them only after its `frame_interpolation_fps() == 0` early-out).
@@ -1891,6 +1891,104 @@ static uint32_t matrix_index_prefix_size(GXVtxFmt fmt) noexcept {
return size; return size;
} }
// Screen-space bounds of a simple orthographic rectangle or line, textured or
// not: MKW's split-screen partition is a layout picture pane (a one-pixel quad
// sampling a pattern texture), so texture use cannot disqualify a candidate.
// The geometry rules in is_split_screen_furniture keep HUD art visible.
static std::optional<gfx::stereo_replay::SubviewRect> screen_rect(GXPrimitive prim, GXVtxFmt fmt,
const uint8_t* vertices, uint16_t count,
uint32_t stride) noexcept {
if (!aurora::stereo_frame_provider_active() || g_gxState.projType != GX_ORTHOGRAPHIC ||
!((count == 4 && (prim == GX_QUADS || prim == GX_TRIANGLESTRIP || prim == GX_TRIANGLEFAN)) ||
(count == 2 && prim == GX_LINES)))
return {};
const auto& projection = g_gxState.proj;
if (!gfx::stereo_replay::is_orthographic_projection(projection))
return {};
const auto& attr = g_gxState.vtxFmts[fmt].attrs[GX_VA_POS];
const uint32_t components = attr.cnt == GX_POS_XY ? 2 : 3;
const uint32_t componentBytes = comp_type_size(GX_VA_POS, attr.type);
if (componentBytes == 0)
return {};
const uint32_t offset = matrix_index_prefix_size(fmt);
std::array<std::array<float, 2>, 4> points{};
float left = INFINITY, top = INFINITY, right = -INFINITY, bottom = -INFINITY;
for (uint32_t i = 0; i < count; ++i) {
const auto* vertex = vertices + i * stride;
const auto* position = vertex + offset;
bool bigEndian = true;
const auto type = g_gxState.vtxDesc[GX_VA_POS];
if (type == GX_INDEX8 || type == GX_INDEX16) {
const uint32_t index = type == GX_INDEX8 ? *position : read_u16(position, true);
const auto& array = g_gxState.arrays[GX_VA_POS];
const size_t start = size_t(index) * array.stride;
if (array.data == nullptr || start + components * componentBytes > array.size)
return {};
position = static_cast<const uint8_t*>(array.data) + start;
bigEndian = !array.le;
} else if (type != GX_DIRECT || offset + components * componentBytes > stride) {
return {};
}
std::array<float, 3> point{};
for (uint32_t c = 0; c < components; ++c) {
const auto* value = position + c * componentBytes;
switch (attr.type) {
case GX_U8:
point[c] = *value;
break;
case GX_S8:
point[c] = static_cast<int8_t>(*value);
break;
case GX_U16:
point[c] = read_u16(value, bigEndian);
break;
case GX_S16:
point[c] = static_cast<int16_t>(read_u16(value, bigEndian));
break;
case GX_F32:
point[c] = read_f32(value, bigEndian);
break;
default:
return {};
}
if (attr.type != GX_F32)
point[c] = std::ldexp(point[c], -int(attr.frac));
}
const uint32_t matrixIndex =
g_gxState.vtxDesc[GX_VA_PNMTXIDX] == GX_DIRECT ? vertex[0] / 3u : g_gxState.currentPnMtx;
if (matrixIndex >= g_gxState.pnMtx.size())
return {};
const auto& matrix = g_gxState.pnMtx[matrixIndex].pos;
const auto transform = [](const Vec4<float>& row, const std::array<float, 3>& p) {
return row[0] * p[0] + row[1] * p[1] + row[2] * p[2] + row[3];
};
const std::array<float, 3> view{transform(matrix.m0, point), transform(matrix.m1, point),
transform(matrix.m2, point)};
const auto& vp = g_gxState.renderViewport;
const float x = vp.left + (transform(projection.m0, view) + 1.f) * vp.width * 0.5f;
const float y = vp.top + (1.f - transform(projection.m1, view)) * vp.height * 0.5f;
if (!std::isfinite(x) || !std::isfinite(y))
return {};
points[i] = {x, y};
left = std::min(left, x);
right = std::max(right, x);
top = std::min(top, y);
bottom = std::max(bottom, y);
}
// A diagonal/rotated HUD polygon's bounding box is not a screen mask.
uint32_t corners = 0;
for (uint32_t i = 0; i < count; ++i) {
const auto& p = points[i];
if ((std::abs(p[0] - left) > 0.01f && std::abs(p[0] - right) > 0.01f) ||
(std::abs(p[1] - top) > 0.01f && std::abs(p[1] - bottom) > 0.01f))
return {};
corners |= 1u << ((std::abs(p[0] - right) < 0.01f ? 1 : 0) + (std::abs(p[1] - bottom) < 0.01f ? 2 : 0));
}
if ((count == 4 && corners != 15) || (count == 2 && right - left > 0.01f && bottom - top > 0.01f))
return {};
return gfx::stereo_replay::SubviewRect{left, top, right - left, bottom - top};
}
static HashType draw_geometry_signature(GXVtxFmt fmt, const uint8_t* vertices, uint16_t vtxCount, static HashType draw_geometry_signature(GXVtxFmt fmt, const uint8_t* vertices, uint16_t vtxCount,
uint32_t vtxStride) noexcept { uint32_t vtxStride) noexcept {
Hasher hasher; Hasher hasher;
@@ -2155,7 +2253,7 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui
const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{}; const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{};
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature, handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0, interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
interpolationIdentityActive); interpolationIdentityActive, vertices, vtxSize);
return true; return true;
} }
@@ -2189,7 +2287,7 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
pos += totalVtxBytes; pos += totalVtxBytes;
// Try to merge with previous draw call // Try to merge with previous draw call
if (!g_gxState.stateDirty) if (!g_gxState.stateDirty && !(aurora::stereo_frame_provider_active() && g_gxState.projType == GX_ORTHOGRAPHIC))
LIKELY { LIKELY {
auto* lastDraw = gfx::get_last_draw_command<DrawData>(); auto* lastDraw = gfx::get_last_draw_command<DrawData>();
// Only if the previous draw call was a single instance draw (no lines/points handling) // Only if the previous draw call was a single instance draw (no lines/points handling)
@@ -2223,13 +2321,13 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{}; const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{};
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature, handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0, interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
interpolationIdentityActive); interpolationIdentityActive, vertices, vtxSize);
return true; return true;
} }
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange, static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature, uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
bool interpolationIdentityActive) { bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride) {
ZoneScoped; ZoneScoped;
// GX_CULL_ALL rasterizes nothing on hardware - no color, no depth. // GX_CULL_ALL rasterizes nothing on hardware - no color, no depth.
if (g_gxState.cullMode == GX_CULL_ALL && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS) if (g_gxState.cullMode == GX_CULL_ALL && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS)
@@ -2320,6 +2418,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
.instanceCount = instanceCount, .instanceCount = instanceCount,
.bindGroups = bindGroups, .bindGroups = bindGroups,
.dstAlpha = pipelineState.dstAlpha, .dstAlpha = pipelineState.dstAlpha,
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
}); });
g_gxState.stateDirty = false; g_gxState.stateDirty = false;
} }
+6
View File
@@ -2,6 +2,8 @@
#include "../gfx/common.hpp" #include "../gfx/common.hpp"
#include "shader_info.hpp" #include "shader_info.hpp"
#include "../gfx/stereo_replay.hpp"
#include <optional>
namespace aurora::gx { namespace aurora::gx {
struct DrawData { struct DrawData {
@@ -21,6 +23,10 @@ struct DrawData {
uint32_t instanceCount; uint32_t instanceCount;
GXBindGroups bindGroups; GXBindGroups bindGroups;
uint32_t dstAlpha; uint32_t dstAlpha;
// Valid only for simple orthographic rectangles/lines (textured or not).
// Recorded before merging so VR can omit desktop split masks without
// modifying GX.
std::optional<gfx::stereo_replay::SubviewRect> screenRect;
}; };
constexpr uint32_t GXPipelineConfigVersion = 20; constexpr uint32_t GXPipelineConfigVersion = 20;
+8
View File
@@ -7,6 +7,14 @@ if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32)
target_include_directories(stereo_frame_worker_smoke PRIVATE ../lib) target_include_directories(stereo_frame_worker_smoke PRIVATE ../lib)
target_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi target_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
dawn::dawncpp_headers) dawn::dawncpp_headers)
add_executable(stereo_multiplayer_smoke stereo_multiplayer_smoke.cpp)
target_include_directories(stereo_multiplayer_smoke PRIVATE ../lib)
target_link_libraries(stereo_multiplayer_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
dawn::dawncpp_headers)
add_executable(efb_ram_lifetime_smoke efb_ram_lifetime_smoke.cpp)
target_include_directories(efb_ram_lifetime_smoke PRIVATE ../lib)
target_link_libraries(efb_ram_lifetime_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
dawn::dawncpp_headers)
endif () endif ()
if (NOT TARGET gtest) if (NOT TARGET gtest)
@@ -0,0 +1,146 @@
// Opt-in GPU regression for a depth-probe allocation reused during a scene
// restart. A late map callback must not overwrite the new camera allocation.
#include <aurora/aurora.h>
#include <dolphin/gx.h>
#include "gfx/efb_ram_copy.hpp"
#include "gfx/efb_ram_encoder.hpp"
#include "webgpu/gpu.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <thread>
using namespace aurora;
namespace {
constexpr uint8_t kCameraByte = 0x35;
std::array<uint8_t, 256> guest;
gfx::TextureHandle MakeTexture(uint32_t width, uint32_t height, std::array<uint8_t, 4> pixel) {
auto texture = gfx::new_render_texture(width, height, GX_TF_RGBA8, "Probe lifetime test");
if (texture->format == wgpu::TextureFormat::BGRA8Unorm)
std::swap(pixel[0], pixel[2]);
std::array<uint8_t, 256> pixels;
for (size_t i = 0; i < width * height; ++i)
std::copy(pixel.begin(), pixel.end(), pixels.begin() + i * 4);
const wgpu::TexelCopyTextureInfo destination{.texture = texture->texture};
const wgpu::TexelCopyBufferLayout layout{.bytesPerRow = width * 4, .rowsPerImage = height};
webgpu::g_queue.WriteTexture(&destination, pixels.data(), width * height * 4, &layout, &texture->size);
return texture;
}
void SubmitProbe(const gfx::TextureHandle& texture, GXTexFmt format, uint32_t width, uint32_t height) {
gfx::efb_ram::schedule(guest.data(), width, height, format, texture);
gfx::efb_ram::seal_async_downloads();
auto encoder = webgpu::g_device.CreateCommandEncoder();
gfx::efb_ram::encode_async_downloads(encoder);
auto commands = encoder.Finish();
webgpu::g_queue.Submit(1, &commands);
// The guest frees the probe and constructs a camera at the same address,
// before the readback callback is registered.
guest.fill(kCameraByte);
gfx::efb_ram::after_submit();
}
bool CameraIntact() {
return std::all_of(guest.begin(), guest.end(), [](uint8_t byte) { return byte == kCameraByte; });
}
bool AwaitProbe(const gfx::TextureHandle& texture, GXTexFmt format, uint32_t width, uint32_t height,
const std::array<uint8_t, 4>& pixel) {
const size_t size = gfx::efb_ram::encoded_size(format, width, height);
std::array<uint8_t, 256> pixels{}, expected{};
for (size_t i = 0; i < width * height; ++i)
std::copy(pixel.begin(), pixel.end(), pixels.begin() + i * 4);
if (!gfx::efb_ram::encode(expected.data(), size, format, width, height, pixels.data(), width, height, width * 4,
gfx::efb_ram::HostPixelOrder::RGBA))
return false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
do {
// Give the real GPU completion callback a chance to run while the old
// destination belongs to the camera, then verify it remains untouched.
webgpu::g_instance.ProcessEvents();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (!CameraIntact()) {
std::fprintf(stderr, "Late GPU readback overwrote the reused camera allocation\n");
return false;
}
// This explicit copy owns the destination again and may receive the result.
gfx::efb_ram::schedule(guest.data(), width, height, format, texture);
const bool arrived = std::equal(expected.begin(), expected.begin() + size, guest.begin());
const bool canary =
std::all_of(guest.begin() + size, guest.end(), [](uint8_t byte) { return byte == kCameraByte; });
gfx::efb_ram::cancel();
guest.fill(kCameraByte);
if (!canary)
return false;
if (arrived)
return true;
} while (std::chrono::steady_clock::now() < deadline);
std::fprintf(stderr, "Completed probe was not delivered by the next compatible copy\n");
return false;
}
bool Run() {
const std::array<uint8_t, 4> pixel{0xff, 0xfa, 0xb6, 0xff};
auto large = MakeTexture(8, 8, pixel);
auto small = MakeTexture(4, 4, pixel);
SubmitProbe(large, GX_TF_Z24X8, 8, 8);
if (!AwaitProbe(large, GX_TF_Z24X8, 8, 8, pixel))
return false;
std::puts("Reused camera allocation survives late 256-byte probe: PASS");
// Same address, smaller allocation and different encoding: the retained
// 256-byte result must not escape the new 32-byte destination.
gfx::efb_ram::schedule(guest.data(), 4, 4, GX_TF_Z16, small);
const bool fallback = std::all_of(guest.begin(), guest.begin() + 32, [](uint8_t b) { return b == 0xff; });
const bool canary = std::all_of(guest.begin() + 32, guest.end(), [](uint8_t b) { return b == kCameraByte; });
gfx::efb_ram::cancel();
if (!fallback || !canary)
return false;
std::puts("Reused address rejects incompatible retained format and size: PASS");
SubmitProbe(small, GX_TF_Z24X8, 4, 4);
if (!AwaitProbe(small, GX_TF_Z24X8, 4, 4, pixel))
return false;
std::puts("Crash-dump 64-byte depth pattern delivered only during a new copy: PASS");
SubmitProbe(small, GX_TF_Z16, 4, 4);
if (!AwaitProbe(small, GX_TF_Z16, 4, 4, pixel))
return false;
std::puts("Changed probe format delivers compatible pixels without touching canaries: PASS");
return true;
}
} // namespace
int main(int argc, char** argv) {
std::setvbuf(stdout, nullptr, _IONBF, 0);
std::filesystem::create_directories("efb-lifetime-cache");
AuroraConfig config{};
config.appName = "Aurora EFB lifetime validation";
config.userPath = ".";
config.cachePath = "efb-lifetime-cache";
config.desiredBackend = BACKEND_D3D12;
config.windowWidth = 160;
config.windowHeight = 120;
config.hasWindowPosition = true;
config.windowPosX = config.windowPosY = -30000;
config.xrInterop = true;
config.logLevel = LOG_WARNING;
config.logCallback = [](AuroraLogLevel, const char* module, const char* message, unsigned length) {
std::fprintf(stderr, "%s: %.*s\n", module, int(length), message);
};
aurora_initialize(argc, argv, &config);
std::puts("GPU initialized");
aurora_begin_frame();
GXInit(nullptr, 0);
std::puts("Starting readback lifetime checks");
const bool passed = Run();
std::puts("Readback lifetime checks finished");
aurora_end_frame();
aurora_quiesce_frame_worker();
aurora_shutdown();
return passed ? 0 : 1;
}
+65
View File
@@ -2269,6 +2269,71 @@ TEST_F(GXFifoTest, MergedDrawOffsetsCachedTopologyWithoutJoiningPrimitives) {
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{3, 4, 5})); EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{3, 4, 5}));
} }
TEST_F(GXFifoTest, OrthographicQuadRecordsScreenRectForVrFurniture) {
// MKW draws its split-screen partition with the partition_line layout: a
// one-pixel picture pane sampling a pattern texture in a full-display
// orthographic viewport. VR replay drops it by its recorded screen
// rectangle. This covers the FIFO decode of that rectangle. The harness
// stubs populate_pipeline_config, so whether texture use disqualifies a
// rectangle is exercised by stereo_multiplayer_smoke instead.
aurora::gfx::testing::use_real_vertex_format_helpers(true);
aurora::gfx::testing::use_draw_command_tracking(true);
aurora::Mat4x4<float> proj{};
proj.m0[0] = 2.0f / 640.0f;
proj.m0[3] = -1.0f;
proj.m1[1] = 2.0f / 480.0f;
proj.m1[3] = -1.0f;
proj.m2[2] = -1.0f;
proj.m3[3] = 1.0f;
GXSetProjection(&proj, GX_ORTHOGRAPHIC);
GXSetViewport(0.0f, 0.0f, 640.0f, 480.0f, 0.0f, 1.0f);
GXSetScissor(0, 0, 640, 480);
aurora::Mat3x4<float> identity{};
identity.m0[0] = identity.m1[1] = identity.m2[2] = 1.0f;
GXLoadPosMtxImm(&identity, GX_PNMTX0);
GXSetCurrentMtx(GX_PNMTX0);
GXClearVtxDesc();
GXSetVtxDesc(GX_VA_POS, GX_DIRECT);
GXSetVtxDesc(GX_VA_CLR0, GX_DIRECT);
GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0);
GXSetNumChans(1);
GXSetNumTexGens(1);
GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_IDENTITY);
GXSetNumTevStages(1);
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR0A0);
GXSetTevOp(GX_TEVSTAGE0, GX_MODULATE);
alignas(32) static const u8 pattern[8 * 8 * 2]{};
GXTexObj obj{};
GXInitTexObj(&obj, pattern, 8, 8, GX_TF_RGB565, GX_CLAMP, GX_CLAMP, GX_FALSE);
GXLoadTexObj(&obj, GX_TEXMAP0);
// yoko_line: full width, one pixel tall, centred vertically.
const float corners[4][2]{{0.0f, 239.5f}, {640.0f, 239.5f}, {640.0f, 240.5f}, {0.0f, 240.5f}};
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& corner : corners) {
GXPosition3f32(corner[0], corner[1], 0.0f);
GXColor4u8(0, 0, 0, 255);
GXTexCoord2f32(corner[0] / 640.0f, corner[1] > 240.0f ? 1.0f : 0.0f);
}
GXEnd();
decode_fifo(flush_and_capture());
const auto* draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
ASSERT_TRUE(draw->screenRect.has_value());
EXPECT_NEAR(draw->screenRect->left, 0.0f, 0.01f);
EXPECT_NEAR(draw->screenRect->top, 239.5f, 0.01f);
EXPECT_NEAR(draw->screenRect->width, 640.0f, 0.01f);
EXPECT_NEAR(draw->screenRect->height, 1.0f, 0.01f);
EXPECT_TRUE(
aurora::gfx::stereo_replay::is_split_screen_furniture(*draw->screenRect, {0.0f, 0.0f, 640.0f, 480.0f}, 2));
}
TEST_F(GXFifoTest, TexBufferSize_UsesExactLinearPcFormatSizes) { TEST_F(GXFifoTest, TexBufferSize_UsesExactLinearPcFormatSizes) {
EXPECT_EQ(GXGetTexBufferSize(8, 4, GX_TF_R8_PC, GX_FALSE, 0), 32u); EXPECT_EQ(GXGetTexBufferSize(8, 4, GX_TF_R8_PC, GX_FALSE, 0), 32u);
EXPECT_EQ(GXGetTexBufferSize(8, 4, GX_TF_RGBA8_PC, GX_FALSE, 0), 128u); EXPECT_EQ(GXGetTexBufferSize(8, 4, GX_TF_RGBA8_PC, GX_FALSE, 0), 128u);
@@ -0,0 +1,265 @@
// Opt-in D3D12 readback test: P1 is red, P2 green, P3 blue, P4 yellow.
// Both eyes must be entirely P1 while the original EFB keeps every pane.
#include <aurora/aurora.h>
#include <aurora/gfx.h>
#include <dolphin/gx.h>
#include <dolphin/mtx.h>
#include "stereo.hpp"
#include "gfx/common.hpp"
#include <array>
#include <cstdio>
#include <filesystem>
using namespace aurora::webgpu;
namespace {
// Eyes match the EFB width so a one-pixel-class divider on the virtual screen
// still covers eye pixels; a 160x120 eye never rasterized it.
constexpr uint32_t kWidth = 640, kHeight = 480, kEfbWidth = 640, kEfbHeight = 528, kPitch = 2560;
constexpr uint64_t kBytes = kPitch * kEfbHeight;
std::array<wgpu::Buffer, 3> readbacks;
std::array<wgpu::TextureFormat, 3> formats;
uint64_t token = 0;
uint32_t copies = 0;
bool Provide(uint32_t, AuroraStereoFrame* frame, void*) {
*frame = {};
frame->frameToken = ++token;
frame->contentTag = 42;
for (auto& eye : frame->eyes) {
eye.width = kWidth;
eye.height = kHeight;
eye.projection[0] = eye.projection[5] = 1;
eye.projection[10] = eye.projection[11] = eye.projection[14] = -1;
eye.viewFromCenter[0] = eye.viewFromCenter[5] = eye.viewFromCenter[10] = 1;
}
return true;
}
bool Encode(wgpu::CommandEncoder& encoder, const aurora::stereo::SinkFrame& frame, void*) noexcept {
for (uint32_t i = 0; i < 3; ++i) {
const auto texture = i < 2 ? *frame.eyes[i].texture : present_source().texture;
formats[i] = texture.GetFormat();
const wgpu::TexelCopyTextureInfo source{.texture = texture};
const wgpu::TexelCopyBufferInfo destination{.layout = {.bytesPerRow = kPitch, .rowsPerImage = kEfbHeight},
.buffer = readbacks[i]};
const wgpu::Extent3D size{i < 2 ? kWidth : kEfbWidth, i < 2 ? kHeight : kEfbHeight, 1};
encoder.CopyTextureToBuffer(&source, &destination, &size);
}
++copies;
return true;
}
void Draw(uint32_t players) {
// GX depth occupies [-w, 0], unlike OpenGL's [-w, +w].
Mtx44 projection{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, -1}, {0, 0, -1, 0}};
Mtx transform{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, -2}};
GXSetProjection(projection, GX_PERSPECTIVE);
GXSetCurrentMtx(GX_PNMTX0);
GXLoadPosMtxImm(transform, GX_PNMTX0);
GXClearVtxDesc();
GXSetVtxDesc(GX_VA_POS, GX_DIRECT);
GXSetVtxDesc(GX_VA_CLR0, GX_DIRECT);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_POS, GX_POS_XYZ, GX_F32, 0);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_CLR0, GX_CLR_RGBA, GX_RGBA8, 0);
GXSetNumTexGens(0);
GXSetNumChans(1);
GXSetChanCtrl(GX_COLOR0A0, GX_FALSE, GX_SRC_REG, GX_SRC_VTX, GX_LIGHT_NULL, GX_DF_NONE, GX_AF_NONE);
GXSetNumTevStages(1);
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD_NULL, GX_TEXMAP_NULL, GX_COLOR0A0);
GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_ZERO, GX_CC_ZERO, GX_CC_RASC);
GXSetTevAlphaIn(GX_TEVSTAGE0, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_RASA);
GXSetTevColorOp(GX_TEVSTAGE0, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV);
GXSetTevAlphaOp(GX_TEVSTAGE0, GX_TEV_ADD, GX_TB_ZERO, GX_CS_SCALE_1, GX_TRUE, GX_TEVPREV);
GXSetZMode(GX_FALSE, GX_ALWAYS, GX_FALSE);
GXSetAlphaCompare(GX_ALWAYS, 0, GX_AOP_AND, GX_ALWAYS, 0);
GXSetCullMode(GX_CULL_NONE);
GXSetBlendMode(GX_BM_NONE, GX_BL_ONE, GX_BL_ZERO, GX_LO_COPY);
GXSetColorUpdate(GX_TRUE);
GXSetAlphaUpdate(GX_TRUE);
const GXColor colors[]{{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}};
for (uint32_t p = 0; p < players; ++p) {
const uint32_t width = players >= 3 ? kEfbWidth / 2 : kEfbWidth;
const uint32_t height = players >= 2 ? kEfbHeight / 2 : kEfbHeight;
const uint32_t x = players >= 3 ? (p % 2) * width : 0;
const uint32_t y = players >= 3 ? (p / 2) * height : p * height;
GXSetViewport(float(x), float(y), float(width), float(height), 0, 1);
GXSetScissor(x, y, width, height);
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
GXPosition3f32(-2, -2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXPosition3f32(2, -2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXPosition3f32(2, 2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXPosition3f32(-2, 2, 0);
GXColor4u8(colors[p].r, colors[p].g, colors[p].b, 255);
GXEnd();
}
if (players == 1)
return;
// MKW's separators and per-pane backing quads can share one full-screen
// orthographic viewport. Viewport filtering alone must not admit them in VR.
Mtx44 ortho{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, -0.5f}, {0, 0, 0, 1}};
Mtx identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
GXSetProjection(ortho, GX_ORTHOGRAPHIC);
GXLoadPosMtxImm(identity, GX_PNMTX0);
GXSetViewport(0, 0, kEfbWidth, kEfbHeight, 0, 1);
GXSetScissor(0, 0, kEfbWidth, kEfbHeight);
const auto rect = [](float left, float bottom, float right, float top) {
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& p :
std::array<std::array<float, 2>, 4>{{{left, bottom}, {right, bottom}, {right, top}, {left, top}}}) {
GXPosition3f32(p[0], p[1], 0);
GXColor4u8(0, 0, 0, 255);
}
GXEnd();
};
if (players >= 3)
rect(0, 0, 1, 1); // The reported black quadrant.
else
rect(-1, -1, 1, 0);
if (players >= 3) {
GXSetLineWidth(6, GX_TO_ZERO);
GXBegin(GX_LINES, GX_VTXFMT0, 2);
GXPosition3f32(0, -1, 0);
GXColor4u8(0, 0, 0, 255);
GXPosition3f32(0, 1, 0);
GXColor4u8(0, 0, 0, 255);
GXEnd();
}
// MKW's partition_line layout draws the divider as one-pixel picture panes
// that sample a pattern texture, so the divider here is a textured quad too:
// texture use alone must not keep it out of the furniture filter.
alignas(32) static const uint8_t blackTexels[8 * 8 * 2]{}; // RGB565 zero: opaque black.
GXTexObj divider{};
GXInitTexObj(&divider, blackTexels, 8, 8, GX_TF_RGB565, GX_CLAMP, GX_CLAMP, GX_FALSE);
GXLoadTexObj(&divider, GX_TEXMAP0);
GXSetVtxDesc(GX_VA_TEX0, GX_DIRECT);
GXSetVtxAttrFmt(GX_VTXFMT0, GX_VA_TEX0, GX_TEX_ST, GX_F32, 0);
GXSetNumTexGens(1);
GXSetTexCoordGen(GX_TEXCOORD0, GX_TG_MTX2x4, GX_TG_TEX0, GX_IDENTITY);
GXSetTevOrder(GX_TEVSTAGE0, GX_TEXCOORD0, GX_TEXMAP0, GX_COLOR0A0);
GXSetTevColorIn(GX_TEVSTAGE0, GX_CC_ZERO, GX_CC_ZERO, GX_CC_ZERO, GX_CC_TEXC);
GXSetTevAlphaIn(GX_TEVSTAGE0, GX_CA_ZERO, GX_CA_ZERO, GX_CA_ZERO, GX_CA_TEXA);
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& p :
std::array<std::array<float, 2>, 4>{{{-1, -0.006f}, {1, -0.006f}, {1, 0.006f}, {-1, 0.006f}}}) {
GXPosition3f32(p[0], p[1], 0);
GXColor4u8(255, 255, 255, 255);
GXTexCoord2f32(p[0] * 0.5f + 0.5f, p[1] > 0 ? 1.f : 0.f);
}
GXEnd();
}
bool Check(uint32_t players) {
bool okay = copies != 0;
for (uint32_t i = 0; i < 3; ++i) {
wgpu::MapAsyncStatus status{};
const auto future = readbacks[i].MapAsync(wgpu::MapMode::Read, 0, kBytes, wgpu::CallbackMode::WaitAnyOnly,
[&](wgpu::MapAsyncStatus result, wgpu::StringView) { status = result; });
if (g_instance.WaitAny(future, 5'000'000'000) != wgpu::WaitStatus::Success ||
status != wgpu::MapAsyncStatus::Success) {
return false;
}
const auto* bytes = static_cast<const uint8_t*>(readbacks[i].GetConstMappedRange());
for (uint32_t quadrant = 0; quadrant < 4; ++quadrant) {
const uint32_t width = i < 2 ? kWidth : kEfbWidth;
const uint32_t height = i < 2 ? kHeight : kEfbHeight;
const uint32_t x = (quadrant % 2) * width / 2 + width / 4;
const uint32_t y = (quadrant / 2) * height / 2 + height / 4;
uint32_t player = i < 2 || players == 1 ? 0 : players == 2 ? quadrant / 2 : quadrant;
if (player >= players)
continue;
const auto* pixel = bytes + y * kPitch + x * 4;
const bool bgra = formats[i] == wgpu::TextureFormat::BGRA8Unorm;
const uint8_t r = pixel[bgra ? 2 : 0], g = pixel[1], b = pixel[bgra ? 0 : 2];
const bool mask = i == 2 && players > 1 && (players == 2 ? quadrant >= 2 : quadrant == 1);
const bool match = r == (!mask && (player == 0 || player == 3) ? 255 : 0) &&
g == (!mask && (player == 1 || player == 3) ? 255 : 0) &&
b == (!mask && player == 2 ? 255 : 0);
if (!match)
std::fprintf(stderr, "%uP target %u quadrant %u expected player %u, got %u,%u,%u\n", players, i, quadrant,
player + 1, r, g, b);
okay &= match;
}
if (i < 2) {
// Include the divider locations; quadrant-center samples alone miss them.
for (uint32_t y = 4; y < kHeight - 4; ++y) {
for (uint32_t x = 4; x < kWidth - 4; ++x) {
const auto* pixel = bytes + y * kPitch + x * 4;
const bool bgra = formats[i] == wgpu::TextureFormat::BGRA8Unorm;
if (pixel[bgra ? 2 : 0] != 255 || pixel[1] != 0 || pixel[bgra ? 0 : 2] != 0) {
std::fprintf(stderr, "%uP eye %u has split-screen overlay at %u,%u\n", players, i, x, y);
okay = false;
y = kHeight;
break;
}
}
}
} else if (players > 1) {
const auto* divider = bytes + (kEfbHeight / 2) * kPitch + (kEfbWidth / 4) * 4;
if (divider[0] != 0 || divider[1] != 0 || divider[2] != 0) {
std::fprintf(stderr, "Desktop divider was removed\n");
okay = false;
}
}
readbacks[i].Unmap();
}
return okay;
}
} // namespace
int main(int argc, char** argv) {
std::filesystem::create_directories("stereo-multiplayer-cache");
AuroraConfig config{};
config.appName = "Aurora multiplayer VR validation";
config.userPath = ".";
config.cachePath = "stereo-multiplayer-cache";
config.desiredBackend = BACKEND_D3D12;
config.windowWidth = kWidth;
config.windowHeight = kHeight;
config.hasWindowPosition = true;
config.windowPosX = config.windowPosY = -30000;
config.xrInterop = true;
config.logLevel = LOG_WARNING;
config.logCallback = [](AuroraLogLevel, const char* module, const char* message, unsigned length) {
std::fprintf(stderr, "%s: %.*s\n", module, int(length), message);
};
aurora_initialize(argc, argv, &config);
aurora_set_skip_unready_pipelines(false);
aurora_begin_frame();
GXInit(nullptr, 0);
const wgpu::BufferDescriptor descriptor{.usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst,
.size = kBytes};
for (auto& buffer : readbacks)
buffer = g_device.CreateBuffer(&descriptor);
aurora_set_stereo_frame_provider(Provide, nullptr);
aurora::gfx::set_stereo_hud_screen(true, 2.f, 1.f);
aurora::stereo::set_sink(Encode, nullptr);
bool okay = true;
for (bool interpolate : {false, true}) {
aurora_set_stereo_frame_interpolation(interpolate);
for (uint32_t players : {1u, 2u, 3u, 4u, 1u}) {
for (uint32_t frame = 0; frame < 3; ++frame) {
aurora_update();
if (!aurora_begin_frame())
return 2;
Draw(players);
// Deliberately omit the setter for 1P to exercise per-frame reset.
if (players > 1)
aurora_set_stereo_local_player_count(players);
aurora_end_frame_tagged(42);
aurora_begin_frame();
aurora_wait_for_frame_worker();
}
const bool passed = Check(players);
okay &= passed;
std::printf("%u players, interpolation %s: %s\n", players, interpolate ? "on" : "off", passed ? "PASS" : "FAIL");
}
}
aurora_set_stereo_frame_interpolation(false);
aurora_quiesce_frame_worker();
aurora_set_stereo_frame_provider(nullptr, nullptr);
aurora::stereo::set_sink(nullptr, nullptr);
for (auto& buffer : readbacks)
buffer = nullptr;
aurora_shutdown();
return okay ? 0 : 1;
}
+83
View File
@@ -8,6 +8,89 @@
namespace aurora::gfx::stereo_replay { namespace aurora::gfx::stereo_replay {
namespace { namespace {
TEST(StereoReplayTest, SplitFurnitureIsRecognizedInFullDisplayCoordinates) {
const SubviewRect display{16.f, 8.f, 1280.f, 912.f};
for (uint32_t players : {2u, 3u, 4u}) {
EXPECT_TRUE(is_split_screen_furniture({16.f, 462.f, 1280.f, 4.f}, display, players));
EXPECT_FALSE(is_split_screen_furniture(display, display, players)); // Race fade.
EXPECT_FALSE(is_split_screen_furniture({80.f, 60.f, 100.f, 40.f}, display, players)); // HUD backing.
EXPECT_FALSE(is_split_screen_furniture(player_one_region(display, players), display, players));
}
EXPECT_TRUE(is_split_screen_furniture({16.f, 464.f, 1280.f, 456.f}, display, 2));
for (uint32_t players : {3u, 4u}) {
EXPECT_TRUE(is_split_screen_furniture({656.f, 8.f, 0.f, 912.f}, display, players));
EXPECT_TRUE(is_split_screen_furniture({658.f, 10.f, 636.f, 452.f}, display, players));
EXPECT_TRUE(is_split_screen_furniture({16.f, 464.f, 640.f, 456.f}, display, players));
EXPECT_TRUE(is_split_screen_furniture({656.f, 464.f, 640.f, 456.f}, display, players));
}
EXPECT_FALSE(is_split_screen_furniture({656.f, 8.f, 640.f, 456.f}, display, 1));
EXPECT_FALSE(is_split_screen_furniture({656.f, 8.f, 0.f, 912.f}, display, 2));
}
TEST(StereoReplayTest, PartitionLineLayoutPanesAreFurniture) {
// MKW's partition_line.brlyt draws yoko_line (800x1) and tate_line (1x800)
// picture panes centred on the display; both extend past a 4:3 root and are
// clipped by the display copy, so only the centre line and thickness matter.
const SubviewRect display{0.f, 0.f, 893.f, 456.f};
const SubviewRect yoko{46.5f, 227.5f, 800.f, 1.f};
const SubviewRect tate{446.f, -172.f, 1.f, 800.f};
EXPECT_TRUE(is_split_screen_furniture(yoko, display, 2));
EXPECT_FALSE(is_split_screen_furniture(tate, display, 2));
for (uint32_t players : {3u, 4u}) {
EXPECT_TRUE(is_split_screen_furniture(yoko, display, players));
EXPECT_TRUE(is_split_screen_furniture(tate, display, players));
}
// A textured pane of the same shape elsewhere is HUD art, not furniture.
EXPECT_FALSE(is_split_screen_furniture({46.5f, 100.f, 800.f, 1.f}, display, 2));
EXPECT_FALSE(is_split_screen_furniture({46.5f, 227.5f, 300.f, 1.f}, display, 2));
}
TEST(StereoReplayTest, MultiplayerSelectsOnlyPlayerOneWorld) {
const SubviewRect display{12.f, 8.f, 640.f, 456.f};
for (uint32_t count : {2u, 3u, 4u}) {
const auto player = player_one_region(display, count);
EXPECT_FLOAT_EQ(player.left, display.left);
EXPECT_FLOAT_EQ(player.top, display.top);
EXPECT_FLOAT_EQ(player.width, count == 2 ? 640.f : 320.f);
EXPECT_FLOAT_EQ(player.height, 228.f);
EXPECT_TRUE(replay_player_one_draw(player, player, true, false));
auto opponent = player;
opponent.top += player.height;
EXPECT_FALSE(replay_player_one_draw(opponent, player, true, false));
EXPECT_FALSE(replay_player_one_draw(opponent, player, false, false));
if (count >= 3) {
opponent = player;
opponent.left += player.width;
EXPECT_FALSE(replay_player_one_draw(opponent, player, true, false));
EXPECT_FALSE(replay_player_one_draw(opponent, player, false, false));
opponent.top += player.height; // P4 / unused fourth quadrant in 3P.
EXPECT_FALSE(replay_player_one_draw(opponent, player, true, false));
}
EXPECT_FALSE(replay_player_one_draw(display, player, true, false));
EXPECT_TRUE(replay_player_one_draw(display, player, false, false));
EXPECT_FALSE(replay_player_one_draw(player, player, false, true));
EXPECT_FALSE(replay_player_one_draw(display, player, false, true));
const auto remap = make_hud_ndc_remap(player.left, player.top, player.width, player.height, player.left, player.top,
player.width, player.height);
EXPECT_FLOAT_EQ(remap.scaleX, 1.f);
EXPECT_FLOAT_EQ(remap.scaleY, 1.f);
EXPECT_FLOAT_EQ(remap.offsetX, 0.f);
EXPECT_FLOAT_EQ(remap.offsetY, 0.f);
}
const auto single = player_one_region(display, 1);
EXPECT_FLOAT_EQ(single.width, display.width);
EXPECT_FLOAT_EQ(single.height, display.height);
}
TEST(StereoReplayTest, MultiplayerRejectsEmptyAndNonOverlappingScissors) {
const SubviewRect player{0.f, 0.f, 320.f, 228.f};
EXPECT_FALSE(subviews_overlap(player, {320.f, 0.f, 320.f, 228.f}));
EXPECT_FALSE(subviews_overlap(player, {0.f, 228.f, 640.f, 228.f}));
EXPECT_FALSE(subviews_overlap(player, {10.f, 10.f, 0.f, 10.f}));
EXPECT_TRUE(subviews_overlap(player, {10.f, 10.f, 20.f, 20.f}));
EXPECT_TRUE(subview_contains(player, {0.f, -0.5f, 320.f, 228.f}));
}
TEST(StereoReplayTest, EyeFrustumPreservesGameDepthMapping) { TEST(StereoReplayTest, EyeFrustumPreservesGameDepthMapping) {
const Mat4x4<float> game{ const Mat4x4<float> game{
{10.0f, 11.0f, 12.0f, 13.0f}, {10.0f, 11.0f, 12.0f, 13.0f},
+8
View File
@@ -356,6 +356,14 @@ target_include_directories(mkw_vr_player_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR
target_compile_features(mkw_vr_player_tests PRIVATE cxx_std_17) target_compile_features(mkw_vr_player_tests PRIVATE cxx_std_17)
add_test(NAME mkw_vr_player_tests COMMAND mkw_vr_player_tests) add_test(NAME mkw_vr_player_tests COMMAND mkw_vr_player_tests)
add_executable(mkw_vr_policy_tests tests/vr_policy_tests.cpp src/vr/mkw_vr_policy.cpp)
target_include_directories(mkw_vr_policy_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
target_compile_features(mkw_vr_policy_tests PRIVATE cxx_std_17)
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
target_compile_options(mkw_vr_policy_tests PRIVATE -ffast-math)
endif()
add_test(NAME mkw_vr_policy_tests COMMAND mkw_vr_policy_tests)
if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS) if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS)
add_executable(mkw_openxr_replay_tests add_executable(mkw_openxr_replay_tests
tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp) tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp)
+3 -2
View File
@@ -147,8 +147,9 @@ extern "C" void GX__CopyTex_8016fd74(uint32_t da, uint32_t c) {
// EFB exactly once, matching Dolphin's ConvertEFBRectangle path. // EFB exactly once, matching Dolphin's ConvertEFBRectangle path.
GXSetTexCopySrc(rawSrcLeft, rawSrcTop, rawSrcWidth, rawSrcHeight); GXSetTexCopySrc(rawSrcLeft, rawSrcTop, rawSrcWidth, rawSrcHeight);
// EFB copies stay GPU-only except probe-sized ones (e.g. the 4x4 lens-flare depth probe), // EFB copies stay GPU-only except probe-sized ones (e.g. the 4x4 lens-flare depth probe),
// which Aurora reads back asynchronously via efb_ram::schedule and land in guest RAM a frame // which Aurora reads back asynchronously and publishes during the next copy to that buffer.
// later. RISK: copies above the probe threshold, or on the offscreen list, are not // GPU callbacks retain pixels in host memory so a scene restart cannot receive a late write
// into a freed/reused allocation. RISK: copies above the probe threshold, or on the offscreen list, are not
// auto-downloaded, so guest reads see stale RAM; call aurora_flush_efb_copies_to_ram if a // auto-downloaded, so guest reads see stale RAM; call aurora_flush_efb_copies_to_ram if a
// copy needs reading back. // copy needs reading back.
GXCopyTex(GuestToHostPtr(da), (GXBool)c); GXCopyTex(GuestToHostPtr(da), (GXBool)c);
+5 -2
View File
@@ -615,8 +615,11 @@ void VI_HLE_PresentFrame(bool presentedXfb, bool paceToRetrace) {
// asynchronous worker may ask for an XR packet after the guest has already // asynchronous worker may ask for an XR packet after the guest has already
// begun the next frame, so immersive replay is accepted only when both // begun the next frame, so immersive replay is accepted only when both
// tags match. // tags match.
const uint64_t vrContentTag = mkw::vr::MkwVRPolicyGetSnapshot().content_tag; const auto vrPolicy = mkw::vr::MkwVRPolicyGetSnapshot();
aurora_end_frame_tagged(vrContentTag); aurora_set_stereo_local_player_count(
vrPolicy.presentation == mkw::vr::VRPresentationMode::ImmersiveRace
? vrPolicy.scene.local_player_count : 1);
aurora_end_frame_tagged(vrPolicy.content_tag);
if (paceThisFrame) { if (paceThisFrame) {
PaceToRetraceBoundary(paceDeadline); PaceToRetraceBoundary(paceDeadline);
std::lock_guard<std::mutex> lock(g_viMutex); std::lock_guard<std::mutex> lock(g_viMutex);
+3 -1
View File
@@ -168,7 +168,9 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address,
case kRaceCameraUpdate: { case kRaceCameraUpdate: {
const uint32_t camera_address = context != nullptr ? context->gpr[3] : 0; const uint32_t camera_address = context != nullptr ? context->gpr[3] : 0;
ObserveCamera(frame, camera_address); ObserveCamera(frame, camera_address);
PublishObservedCamera(frame, camera_address); if (camera_address == FirstCamera(frame)) {
PublishObservedCamera(frame, camera_address);
}
break; break;
} }
case kScnMgrRaceDraw: { case kScnMgrRaceDraw: {
+10 -3
View File
@@ -86,12 +86,13 @@ VRPresentationMode SelectPresentation(const PolicyState& state) noexcept {
return VRPresentationMode::Desktop; return VRPresentationMode::Desktop;
} }
// A virtual screen is the fail-safe for menus, split-screen, and any // A virtual screen is the fail-safe for menus and any
// incomplete instrumentation. It preserves the unmodified render path. // incomplete instrumentation. It preserves the unmodified render path.
if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) != if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) !=
kMkwVRRequiredImmersiveBindings || kMkwVRRequiredImmersiveBindings ||
!state.config.immersive_races || state.scene.mode != VRSceneMode::Race || !state.config.immersive_races || state.scene.mode != VRSceneMode::Race ||
state.scene.local_player_count != 1 || !IsFiniteCamera(state.camera) || (state.scene.local_player_count < 1 || state.scene.local_player_count > 4) ||
!IsFiniteCamera(state.camera) ||
!ObservationsAreCoherent(state.scene, state.camera)) { !ObservationsAreCoherent(state.scene, state.camera)) {
return VRPresentationMode::VirtualScreen; return VRPresentationMode::VirtualScreen;
} }
@@ -111,7 +112,8 @@ VRPresentationMode SelectStablePresentation(const PolicyState& state) noexcept {
if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) != if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) !=
kMkwVRRequiredImmersiveBindings || kMkwVRRequiredImmersiveBindings ||
!state.config.immersive_races || state.scene.mode != VRSceneMode::Race || !state.config.immersive_races || state.scene.mode != VRSceneMode::Race ||
state.scene.local_player_count != 1 || !IsFiniteCamera(state.camera)) { (state.scene.local_player_count < 1 || state.scene.local_player_count > 4) ||
!IsFiniteCamera(state.camera)) {
return VRPresentationMode::VirtualScreen; return VRPresentationMode::VirtualScreen;
} }
@@ -211,6 +213,11 @@ void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept {
void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept { void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept {
std::lock_guard<std::mutex> lock(g_policy_mutex); std::lock_guard<std::mutex> lock(g_policy_mutex);
// A packet for a different split layout must not consume retained content,
// even when both layouts use immersive presentation.
if (scene.local_player_count != g_policy.scene.local_player_count) {
AdvanceSafetyGeneration(g_policy);
}
ApplyPolicyMutation([&] { ApplyPolicyMutation([&] {
if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) { if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) {
// Never carry a camera sample across a menu/replay-to-race transition. // Never carry a camera sample across a menu/replay-to-race transition.
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/mkw_vr_policy.h"
#include <cstring>
#include <iostream>
using namespace mkw::vr;
int main() {
int failures = 0;
const auto check = [&](bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
++failures;
}
};
MkwVRPolicyReset();
MkwVRPolicyConfig config{};
config.enabled = true;
MkwVRPolicyConfigure(config);
MkwVRPolicySetSessionActive(true);
MkwVRPolicySetAvailableBindings(kMkwVRRequiredImmersiveBindings);
MkwVRSceneObservation scene{VRSceneMode::Race, 1, 10};
MkwVRCameraObservation camera{};
camera.view_from_world = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0};
camera.guest_camera_address = 0x81000000;
camera.guest_frame_index = 10;
camera.valid = true;
MkwVRPolicyPublishScene(scene);
MkwVRPolicyPublishRaceCamera(camera);
uint64_t previous_tag = 0;
for (uint32_t players : {1u, 2u, 3u, 4u, 2u, 1u}) {
scene.local_player_count = players;
MkwVRPolicyPublishScene(scene);
const auto snapshot = MkwVRPolicyGetSnapshot();
check(snapshot.presentation == VRPresentationMode::ImmersiveRace, "1-4 players should be immersive");
check(snapshot.content_tag != previous_tag, "layout changes must invalidate retained packets");
previous_tag = snapshot.content_tag;
MkwVRPolicyPublishScene(scene);
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().content_tag == previous_tag, "steady frames must keep the same tag");
}
camera.guest_frame_index = 11;
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::ImmersiveRace,
"adjacent camera publication is coherent");
camera.guest_frame_index = 12;
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "stale camera fails safe");
camera.guest_frame_index = 10;
MkwVRPolicyPublishRaceCamera(camera);
for (uint32_t players : {0u, 5u, UINT32_MAX}) {
scene.local_player_count = players;
MkwVRPolicyPublishScene(scene);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "invalid counts fail safe");
}
scene.local_player_count = 4;
MkwVRPolicyPublishScene(scene);
const uint32_t nan_bits = 0x7fc00000;
std::memcpy(&camera.view_from_world[0], &nan_bits, sizeof(nan_bits));
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "NaN camera fails under fast math");
camera.view_from_world[0] = 1;
MkwVRPolicyPublishRaceCamera(camera);
MkwVRPolicySetAvailableBindings(MkwVRBindingRaceCamera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "partial hooks fail safe");
MkwVRPolicySetAvailableBindings(kMkwVRRequiredImmersiveBindings);
scene.mode = VRSceneMode::FrontEnd;
MkwVRPolicyPublishScene(scene);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "menus use virtual screen");
scene.mode = VRSceneMode::Race;
MkwVRPolicyPublishScene(scene);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::VirtualScreen, "race entry needs fresh camera");
MkwVRPolicyPublishRaceCamera(camera);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::ImmersiveRace, "race resumes with fresh camera");
MkwVRPolicySetSessionActive(false);
check(MkwVRPolicyGetSnapshot().presentation == VRPresentationMode::Desktop, "inactive session uses desktop");
return failures == 0 ? 0 : 1;
}