mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 02:00:14 +02:00
Added Support for Multiplayer Player 1 Immersive View
This commit is contained in:
1 parent
9e37bb6447
commit
3e8bdcfd7b
22 files changed
+1013
-38
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.32</Version>
|
||||
<Version>0.2.38</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
@@ -240,6 +264,13 @@ so it can be enabled once Aurora exposes those handles safely.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
@@ -236,6 +236,10 @@ void aurora_end_frame_tagged(uint64_t contentTag);
|
||||
* provider, which cannot know which frame will consume its packet.
|
||||
*/
|
||||
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)();
|
||||
// Called from the producer thread at bounded intervals while Aurora waits for
|
||||
// the asynchronous frame worker. The callback must not enter Aurora.
|
||||
|
||||
@@ -100,11 +100,13 @@ struct StereoSceneAnchor {
|
||||
0.f, 0.f, 1.f, 0.f,
|
||||
};
|
||||
bool active = false;
|
||||
uint32_t localPlayerCount = 1;
|
||||
};
|
||||
// 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
|
||||
// falls back to the recorded camera instead of freezing on a stale anchor.
|
||||
StereoSceneAnchor g_pendingSceneAnchor;
|
||||
uint32_t g_pendingStereoLocalPlayerCount = 1;
|
||||
|
||||
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
|
||||
// pre-first-frame UINT32_MAX value to logical frame zero.
|
||||
ctx.logicalFrame = gfx::current_frame() + 1;
|
||||
gfx::set_stereo_local_player_count(sceneAnchor.localPlayerCount);
|
||||
if (const auto stereoInput = request_stereo_frame(ctx.logicalFrame, contentTag)) {
|
||||
ctx.stereoInput = stereoInput;
|
||||
ctx.stereoFrameToken = stereoInput->frameToken;
|
||||
@@ -2246,7 +2249,9 @@ void end_frame(uint64_t contentTag) noexcept {
|
||||
#endif
|
||||
// Claim the anchor published for this frame. Clearing it here is what makes a
|
||||
// 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 = {};
|
||||
if (!frame_worker_requested()) {
|
||||
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]) {
|
||||
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) {
|
||||
aurora::g_frameWorkerWaitCallback.store(callback, std::memory_order_release);
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ static void recycle_render_passes(std::vector<RenderPass>& passes) noexcept {
|
||||
struct LateStereoUniform {
|
||||
gx::UniformReplayLayout layout;
|
||||
Viewport viewport;
|
||||
ClipRect displayRegion;
|
||||
Range current;
|
||||
Range previous;
|
||||
std::array<Range, AURORA_STEREO_EYE_COUNT> eyes;
|
||||
@@ -322,9 +323,16 @@ struct LateStereoData {
|
||||
// Advanced for every upload, including synchronous mid-frame EFB readbacks.
|
||||
static std::atomic_uint64_t g_replayBufferGeneration{0};
|
||||
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 {
|
||||
std::vector<RenderPass> passes;
|
||||
LateStereoData stereo;
|
||||
uint32_t localPlayerCount = 1;
|
||||
};
|
||||
|
||||
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 {
|
||||
const StereoDisplaySource displaySource = stereo_display_source(g_renderPasses);
|
||||
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
|
||||
// helper concurrently without touching this diagnostic state.
|
||||
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
|
||||
// when it is 2D content the virtual screen is claiming.
|
||||
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 efbPassCount = 0;
|
||||
size_t perspectiveDrawCount = 0;
|
||||
size_t replayPerspectiveDrawCount = 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) {
|
||||
if (pass.efbTarget) {
|
||||
++efbPassCount;
|
||||
}
|
||||
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 ||
|
||||
!replayed(command.data.draw.gx.uniformReplayLayout)) {
|
||||
continue;
|
||||
@@ -1427,6 +1446,10 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
|
||||
if (!pass.efbTarget) {
|
||||
continue;
|
||||
}
|
||||
if (multiplayer && !stereo_replay::replay_player_one_draw(allocationViewport, playerRegion, layout.perspective,
|
||||
layout.nativeEfbEffect)) {
|
||||
continue;
|
||||
}
|
||||
if (layout.perspective) {
|
||||
++replayPerspectiveDrawCount;
|
||||
} else {
|
||||
@@ -1499,6 +1522,18 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
|
||||
}
|
||||
auto& draw = command.data.draw.gx;
|
||||
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);
|
||||
Mat4x4<float> 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->layout = layout;
|
||||
saved->viewport = drawViewport;
|
||||
saved->displayRegion = uniformRegion;
|
||||
const auto save = [&](const uint8_t* source, uint32_t size) -> Range {
|
||||
if (size == 0)
|
||||
return {};
|
||||
@@ -1544,7 +1580,7 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
|
||||
}
|
||||
const auto& eye = stereoFrame.eyes[eyeIndex];
|
||||
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);
|
||||
std::memcpy(uniform.data(), eyeUniform.data(), range.size);
|
||||
}
|
||||
@@ -1681,6 +1717,7 @@ struct RenderInvocation {
|
||||
uint32_t stereoEye = UINT32_MAX;
|
||||
const ReplayTarget* target = nullptr;
|
||||
ClipRect replaySourceRegion{};
|
||||
uint32_t localPlayerCount = 1;
|
||||
// Inclusive index of the last pass to replay; -1 replays every pass.
|
||||
int32_t replayLastPass = -1;
|
||||
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.
|
||||
g_retiredBindGroups.clear();
|
||||
out.data().stereo = std::move(g_pendingLateStereo);
|
||||
out.data().localPlayerCount = g_stereoLocalPlayerCount;
|
||||
g_stereoLocalPlayerCount = 1;
|
||||
auto& passes = out.data().passes;
|
||||
// The previous cycle already recycled these, so this normally just hands the empty vector, its
|
||||
// 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) {
|
||||
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
|
||||
@@ -1961,6 +2000,7 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster
|
||||
.stereoEye = eye,
|
||||
.target = &stereoFrame.eyes[eye].target,
|
||||
.replaySourceRegion = displaySource.region,
|
||||
.localPlayerCount = frame.data().localPlayerCount,
|
||||
.replayLastPass = lastPass,
|
||||
.finalize = finalize,
|
||||
.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 bool overrideTarget = invocation.target != nullptr && renderPasses[idx].efbTarget;
|
||||
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 sourceHeight = static_cast<int32_t>(sourceSize.height);
|
||||
int32_t sourceRegionLeft = 0;
|
||||
@@ -2086,6 +2132,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
switch (cmd.type) {
|
||||
case CommandType::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
|
||||
// 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
|
||||
@@ -2121,6 +2168,7 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
} break;
|
||||
case CommandType::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 sourceTop = std::clamp(sc.y, sourceRegionTop, sourceRegionBottom);
|
||||
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;
|
||||
switch (draw.type) {
|
||||
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;
|
||||
// Only a 2D draw the virtual screen actually claimed carries a stereo
|
||||
// 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
|
||||
// rectangle would cut the reprojected element away, so it gets the whole
|
||||
// eye and every other draw gets the game's own rectangle back.
|
||||
if (!scissorStateKnown || virtualScreenDraw != hudScreenScissor) {
|
||||
hudScreenScissor = virtualScreenDraw;
|
||||
const bool fullEyeDraw = virtualScreenDraw || (multiplayer && draw.gx.uniformReplayLayout.perspective);
|
||||
if (!scissorStateKnown || fullEyeDraw != hudScreenScissor) {
|
||||
hudScreenScissor = fullEyeDraw;
|
||||
scissorStateKnown = true;
|
||||
apply_scissor(virtualScreenDraw ? fullTargetScissor : recordedScissor);
|
||||
apply_scissor(fullEyeDraw ? fullTargetScissor : recordedScissor);
|
||||
}
|
||||
if (!viewportStateKnown || virtualScreenDraw != hudScreenViewport) {
|
||||
hudScreenViewport = virtualScreenDraw;
|
||||
if (!viewportStateKnown || fullEyeDraw != hudScreenViewport) {
|
||||
hudScreenViewport = fullEyeDraw;
|
||||
viewportStateKnown = true;
|
||||
apply_viewport(virtualScreenDraw);
|
||||
apply_viewport(fullEyeDraw);
|
||||
}
|
||||
gx::render(draw.gx, pass, encodeState, renderPasses[idx].requireReadyPipelines, uniformOverride,
|
||||
virtualScreenDraw ? draw.gx.exactScreenDepthPipeline : 0);
|
||||
} break;
|
||||
case ShaderType::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) {
|
||||
// The scissored twin of the attachment-load-op case above: the copy's
|
||||
// EFB reset, rescaled into eye space, covers the whole eye.
|
||||
|
||||
@@ -316,6 +316,8 @@ struct StereoReplayFrame {
|
||||
};
|
||||
|
||||
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.
|
||||
// Returns false without modifying the mono path when the uniform buffer has
|
||||
// insufficient room for the additional copies.
|
||||
|
||||
@@ -60,7 +60,6 @@ struct AsyncSlot {
|
||||
uint64_t bufferSize = 0;
|
||||
uint32_t bytesPerRow = 0;
|
||||
// Latched at encode time and read by the map callback.
|
||||
void* dest = nullptr;
|
||||
GXTexFmt format = GX_TF_RGBA8;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
@@ -68,6 +67,14 @@ struct AsyncSlot {
|
||||
uint32_t hostHeight = 0;
|
||||
HostPixelOrder order = HostPixelOrder::RGBA;
|
||||
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;
|
||||
@@ -138,17 +145,18 @@ void complete_async_slot(void* dest, wgpu::MapAsyncStatus status, wgpu::StringVi
|
||||
if (status == wgpu::MapAsyncStatus::Success) {
|
||||
const auto* pixels = static_cast<const uint8_t*>(slot.buffer.GetConstMappedRange(0, slot.bufferSize));
|
||||
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);
|
||||
if (!encode(slot.dest, outputSize, slot.format, slot.width, slot.height, pixels, slot.hostWidth, slot.hostHeight,
|
||||
slot.bytesPerRow, slot.order)) {
|
||||
if (!encode(slot.completedData.data(), slot.completedData.size(), slot.format, slot.width, slot.height, pixels,
|
||||
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),
|
||||
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();
|
||||
} 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;
|
||||
} else {
|
||||
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;
|
||||
@@ -395,7 +413,6 @@ void encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept {
|
||||
};
|
||||
encoder.CopyTextureToBuffer(&source, &destination, &texture->size);
|
||||
slot.bytesPerRow = bytesPerRow;
|
||||
slot.dest = pending.dest;
|
||||
slot.format = pending.format;
|
||||
slot.width = pending.width;
|
||||
slot.height = pending.height;
|
||||
|
||||
@@ -15,7 +15,10 @@ bool complete_downloads() noexcept;
|
||||
void cancel() noexcept;
|
||||
|
||||
// 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 encode_async_downloads(const wgpu::CommandEncoder& encoder) noexcept;
|
||||
void after_submit() noexcept;
|
||||
|
||||
@@ -1,9 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
#include <aurora/math.hpp>
|
||||
#include <cmath>
|
||||
|
||||
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
|
||||
// GX draw already contains the depth mapping adjusted for that draw's GX
|
||||
// viewport and Aurora's reversed-Z convention. Replacing the complete matrix
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
// (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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
uint32_t vtxStride) noexcept {
|
||||
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{};
|
||||
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
|
||||
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
|
||||
interpolationIdentityActive);
|
||||
interpolationIdentityActive, vertices, vtxSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2189,7 +2287,7 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
|
||||
pos += totalVtxBytes;
|
||||
|
||||
// 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 {
|
||||
auto* lastDraw = gfx::get_last_draw_command<DrawData>();
|
||||
// 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{};
|
||||
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
|
||||
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
|
||||
interpolationIdentityActive);
|
||||
interpolationIdentityActive, vertices, vtxSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
|
||||
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
|
||||
bool interpolationIdentityActive) {
|
||||
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride) {
|
||||
ZoneScoped;
|
||||
// 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)
|
||||
@@ -2320,6 +2418,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
.instanceCount = instanceCount,
|
||||
.bindGroups = bindGroups,
|
||||
.dstAlpha = pipelineState.dstAlpha,
|
||||
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
|
||||
});
|
||||
g_gxState.stateDirty = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "../gfx/common.hpp"
|
||||
#include "shader_info.hpp"
|
||||
#include "../gfx/stereo_replay.hpp"
|
||||
#include <optional>
|
||||
|
||||
namespace aurora::gx {
|
||||
struct DrawData {
|
||||
@@ -21,6 +23,10 @@ struct DrawData {
|
||||
uint32_t instanceCount;
|
||||
GXBindGroups bindGroups;
|
||||
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;
|
||||
|
||||
@@ -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_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
|
||||
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 ()
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -2269,6 +2269,71 @@ TEST_F(GXFifoTest, MergedDrawOffsetsCachedTopologyWithoutJoiningPrimitives) {
|
||||
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) {
|
||||
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);
|
||||
|
||||
@@ -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(÷r, blackTexels, 8, 8, GX_TF_RGB565, GX_CLAMP, GX_CLAMP, GX_FALSE);
|
||||
GXLoadTexObj(÷r, 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;
|
||||
}
|
||||
@@ -8,6 +8,89 @@
|
||||
namespace aurora::gfx::stereo_replay {
|
||||
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) {
|
||||
const Mat4x4<float> game{
|
||||
{10.0f, 11.0f, 12.0f, 13.0f},
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
add_executable(mkw_openxr_replay_tests
|
||||
tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_d3d12.cpp)
|
||||
|
||||
@@ -147,8 +147,9 @@ extern "C" void GX__CopyTex_8016fd74(uint32_t da, uint32_t c) {
|
||||
// EFB exactly once, matching Dolphin's ConvertEFBRectangle path.
|
||||
GXSetTexCopySrc(rawSrcLeft, rawSrcTop, rawSrcWidth, rawSrcHeight);
|
||||
// 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
|
||||
// later. RISK: copies above the probe threshold, or on the offscreen list, are not
|
||||
// which Aurora reads back asynchronously and publishes during the next copy to that buffer.
|
||||
// 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
|
||||
// copy needs reading back.
|
||||
GXCopyTex(GuestToHostPtr(da), (GXBool)c);
|
||||
|
||||
@@ -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
|
||||
// begun the next frame, so immersive replay is accepted only when both
|
||||
// tags match.
|
||||
const uint64_t vrContentTag = mkw::vr::MkwVRPolicyGetSnapshot().content_tag;
|
||||
aurora_end_frame_tagged(vrContentTag);
|
||||
const auto vrPolicy = mkw::vr::MkwVRPolicyGetSnapshot();
|
||||
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) {
|
||||
PaceToRetraceBoundary(paceDeadline);
|
||||
std::lock_guard<std::mutex> lock(g_viMutex);
|
||||
|
||||
@@ -168,7 +168,9 @@ extern "C" void MkwVRObserveTranslatedFunctionEntry(uint32_t address,
|
||||
case kRaceCameraUpdate: {
|
||||
const uint32_t camera_address = context != nullptr ? context->gpr[3] : 0;
|
||||
ObserveCamera(frame, camera_address);
|
||||
PublishObservedCamera(frame, camera_address);
|
||||
if (camera_address == FirstCamera(frame)) {
|
||||
PublishObservedCamera(frame, camera_address);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case kScnMgrRaceDraw: {
|
||||
|
||||
@@ -86,12 +86,13 @@ VRPresentationMode SelectPresentation(const PolicyState& state) noexcept {
|
||||
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.
|
||||
if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) !=
|
||||
kMkwVRRequiredImmersiveBindings ||
|
||||
!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)) {
|
||||
return VRPresentationMode::VirtualScreen;
|
||||
}
|
||||
@@ -111,7 +112,8 @@ VRPresentationMode SelectStablePresentation(const PolicyState& state) noexcept {
|
||||
if ((state.available_bindings & kMkwVRRequiredImmersiveBindings) !=
|
||||
kMkwVRRequiredImmersiveBindings ||
|
||||
!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;
|
||||
}
|
||||
|
||||
@@ -211,6 +213,11 @@ void MkwVRPolicySetAvailableBindings(uint32_t bindings) noexcept {
|
||||
|
||||
void MkwVRPolicyPublishScene(const MkwVRSceneObservation& scene) noexcept {
|
||||
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([&] {
|
||||
if (scene.mode != VRSceneMode::Race || g_policy.scene.mode != VRSceneMode::Race) {
|
||||
// Never carry a camera sample across a menu/replay-to-race transition.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user