Refactor stereo frame worker and interpolation tests for enhanced VR performance

- Updated stereo_frame_worker_smoke.cpp to allow dynamic headset rates and prediction lead time.
- Improved logging to include motion diagnostics and adjusted frame submission logic based on headset frequency.
- Enhanced stereo_interpolation_test.cpp with additional tests for camera motion separation and playback cadence.
- Introduced MkwVRReadSceneView function to read the camera view matrix for improved scene rendering.
- Modified VR first-person logic to support scene view reading and validation.
- Added scene_camera.hpp to encapsulate camera motion handling and inverse view calculations.
- Ensured that the VR integration layer correctly logs motion diagnostics and handles scene playback accurately.
This commit is contained in:
iChris4 committed 2026-10-01 00:37:49 +02:00
1 parent 85fab2fa09
commit e7eab8a6b2
19 files changed
+1978 -114

No files matched your search

+723
View File
@@ -7,6 +7,7 @@
#include "gfx/texture.hpp"
#include "gx/shader_info.hpp"
#include "gx/pipeline.hpp"
#include "scene_camera.hpp"
#include "__gx.h"
#include <algorithm>
@@ -242,6 +243,631 @@ TEST_F(GXFifoTest, VrKeepsMatchedEndpointsWithDesktopInterpolationOff) {
EXPECT_EQ(build(40, 200, true).previous.size, 0u); // Readback split invalidates replay.
}
TEST_F(GXFifoTest, VrDiagnosticsExposeDistantCameraTurnRejectionDespiteMatchedIdentity) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
const auto build = [&](float yaw) {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const float c = std::cos(yaw), s = std::sin(yaw);
gxState().pnMtx[0].nrm = {{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
for (unsigned i = 0; i < 2; ++i) {
const float distance = i == 0 ? 1000.0f : 100000.0f;
// Two stationary objects, seen from one camera rotating by two degrees.
gxState().pnMtx[0].pos = {{c, 0, s, -s * distance}, {0, 1, 0, 0}, {-s, 0, c, -c * distance}};
aurora::gx::build_uniform(info, 0, {}, {100u + i, 100u + i, 7}, true);
}
aurora::gx::finalize_frame_interpolation();
};
build(0);
build(2.0f * 3.14159265f / 180.0f);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.candidates, 2u);
EXPECT_EQ(diagnostics.matches, 2u);
EXPECT_EQ(diagnostics.preparedDraws, 1u);
EXPECT_EQ(diagnostics.rejectedDraws, 1u);
EXPECT_EQ(diagnostics.framesSealed, 2u); // VR-only frames must count too.
}
TEST_F(GXFifoTest, VrRetainsFastSpinningWheelEndpoints) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
const auto build = [&](float angle, float x) {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const float c = std::cos(angle), s = std::sin(angle);
gxState().pnMtx[0].pos = {{c, -s, 0, x}, {s, c, 0, 0}, {0, 0, 1, -50}};
gxState().pnMtx[0].nrm = {{c, -s, 0, 0}, {s, c, 0, 0}, {0, 0, 1, 0}};
const auto result = aurora::gx::build_uniform(info, 0, {}, {100, 42, 7}, true);
aurora::gx::finalize_frame_interpolation();
return result;
};
EXPECT_EQ(build(0, 10).previous.size, 0u);
const auto uniforms = build(2.0f * 3.14159265f / 3.0f, 30);
ASSERT_NE(uniforms.previous.size, 0u);
const auto& bytes = aurora::gfx::testing::uniform_allocation(uniforms.previous.offset);
aurora::Mat3x4<float> previousPosition{}, previousNormal{};
std::memcpy(static_cast<void*>(&previousPosition), bytes.data() + uniforms.replayLayout.positionOffset,
sizeof(previousPosition));
std::memcpy(static_cast<void*>(&previousNormal), bytes.data() + uniforms.replayLayout.normalOffset,
sizeof(previousNormal));
// Rejecting a >90-degree wheel spin used to copy the current position here too,
// leaving the entire wheel at 60 Hz even as the kart body moved smoothly.
EXPECT_FLOAT_EQ(previousPosition.m0.w(), 10);
EXPECT_FLOAT_EQ(previousPosition.m0.x(), 1);
EXPECT_FLOAT_EQ(previousNormal.m0.x(), 1);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.matches, 1u);
EXPECT_EQ(diagnostics.preparedDraws, 1u);
EXPECT_EQ(diagnostics.rejectedDraws, 0u);
}
TEST_F(GXFifoTest, VrTextureAnimationRetainsSpatialHistoryWithStrictMeshIdentity) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
gxState().pnMtx[0].pos = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, -50}};
gxState().pnMtx[0].nrm = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const auto begin = [&] {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
};
const auto draw = [&](float x, aurora::HashType texture, aurora::HashType geometry = 123,
aurora::HashType topology = 0, aurora::HashType pipeline = 42) {
gxState().pnMtx[0].pos.m0[3] = x;
const aurora::gx::FrameInterpolationDrawIdentity identity{
.combined = texture + 1000, .pipeline = pipeline, .texture = texture,
.matrixTopology = topology, .geometry = geometry};
return aurora::gx::build_uniform(info, 0, {}, identity, true);
};
const auto previousX = [&](const auto& uniforms) {
const auto& bytes = aurora::gfx::testing::uniform_allocation(uniforms.previous.offset);
float x;
std::memcpy(&x, bytes.data() + uniforms.replayLayout.positionOffset + 3 * sizeof(float), sizeof(x));
return x;
};
begin();
draw(10, 1);
draw(100, 2);
draw(200, 3);
aurora::gx::finalize_frame_interpolation();
begin();
// The transparent sort reverses two animated instances. An unchanged exact
// match must also keep priority over the texture-independent mesh fallback.
const auto right = draw(110, 4);
const auto left = draw(20, 4);
const auto unchanged = draw(210, 3);
aurora::gx::finalize_frame_interpolation();
ASSERT_NE(right.previous.size, 0u);
ASSERT_NE(left.previous.size, 0u);
ASSERT_NE(unchanged.previous.size, 0u);
EXPECT_FLOAT_EQ(previousX(right), 100);
EXPECT_FLOAT_EQ(previousX(left), 10);
EXPECT_FLOAT_EQ(previousX(unchanged), 200);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.preparedDraws, 3u);
EXPECT_EQ(diagnostics.rejectedDraws, 0u);
begin();
EXPECT_EQ(draw(120, 5, 456).previous.size, 0u); // Different mesh.
EXPECT_EQ(draw(120, 6, 0).previous.size, 0u); // Missing mesh identity.
EXPECT_EQ(draw(120, 7, 123, 9).previous.size, 0u); // Different palette topology.
EXPECT_EQ(draw(120, 8, 123, 0, 43).previous.size, 0u); // Different pipeline.
aurora::gx::finalize_frame_interpolation();
}
TEST(FrameInterpolationContract, FastRigidSpinsKeepAngularSpeedAndDiscontinuityGuards) {
const aurora::Mat3x4<float> previous{{1, 0, 0, 10}, {0, 1, 0, 0}, {0, 0, 1, 0}};
// Include rotations whose quaternion representation needs hemisphere correction.
for (float degrees : {120.0f, 170.0f, -120.0f, 240.0f}) {
const float angle = degrees * 3.14159265f / 180.0f;
const float c = std::cos(angle), s = std::sin(angle);
const aurora::Mat3x4<float> current{{c, -s, 0, 30}, {s, c, 0, 0}, {0, 0, 1, 0}};
aurora::Mat3x4<float> output{};
EXPECT_FALSE(aurora::gx::interpolate_transform(previous, current, 0.5f, output)); // Anchor cut guard.
for (float weight : {0.0f, 1.0f / 3, 2.0f / 3, 1.0f}) {
ASSERT_TRUE(aurora::gx::interpolate_draw_transform(previous, current, weight, output));
const float expectedAngle = (degrees > 180 ? degrees - 360 : degrees) * 3.14159265f / 180.0f * weight;
EXPECT_NEAR(output.m0.x(), std::cos(expectedAngle), 1e-5f);
EXPECT_NEAR(output.m1.x(), std::sin(expectedAngle), 1e-5f);
EXPECT_NEAR(output.m0.w(), 10 + 20 * weight, 1e-5f);
EXPECT_NEAR(output.m0.x() * output.m0.x() + output.m1.x() * output.m1.x(), 1, 1e-5f);
}
}
aurora::Mat3x4<float> invalid = previous, output{};
invalid.m0[3] = 2010;
EXPECT_FALSE(aurora::gx::interpolate_draw_transform(previous, invalid, 0.5f, output));
EXPECT_FLOAT_EQ(output.m0.w(), 2010);
invalid = previous;
invalid.m0[0] = 0; // Singular axis.
EXPECT_FALSE(aurora::gx::interpolate_draw_transform(previous, invalid, 0.5f, output));
invalid = previous;
invalid.m0[3] = std::numeric_limits<float>::quiet_NaN();
EXPECT_FALSE(aurora::gx::interpolate_draw_transform(previous, invalid, 0.5f, output));
}
TEST(FrameInterpolationContract, ShearedRigidDrawsMoveWithTheSkinnedMeshOnTheirBone) {
// Lakitu::Movement::UpdateScale sways MKW's Lakitu by tilting his Y axis: the model
// matrix gets a Y column of (A cos p, 1, A sin p). His goggles are rigid on the face
// bone and his head is skinned to that same bone. The rigid path rejected the shear,
// holding the goggles at the game frame while the head moved on, so they sank in.
const auto swaying = [](float amplitude, float phase, float lift) {
const float c = std::cos(0.698f), s = std::sin(0.698f); // the face bone's roll
const float x = amplitude * std::cos(phase), z = amplitude * std::sin(phase);
// [[1, x, 0], [0, 1, 0], [0, z, 1]] * Rz, then placed in front of the camera.
return aurora::Mat3x4<float>{{c + x * s, -s + x * c, 0, 10}, {s, c, 0, 50 + lift}, {z * s, z * c, 1, -300}};
};
const auto previous = swaying(0.25f, 0.3f, 0), current = swaying(0.3f, 0.5f, 3);
const auto apply = [](const aurora::Mat3x4<float>& matrix, const std::array<float, 3>& point) {
const aurora::Vec4<float>* rows[] = {&matrix.m0, &matrix.m1, &matrix.m2};
std::array<float, 3> result{};
for (size_t row = 0; row < 3; ++row)
result[row] = (*rows[row])[0] * point[0] + (*rows[row])[1] * point[1] + (*rows[row])[2] * point[2] +
(*rows[row])[3];
return result;
};
const std::array<float, 3> goggleCorner{30, -20, 10};
for (float weight : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f}) {
aurora::Mat3x4<float> head{}, goggles{};
ASSERT_TRUE(aurora::gx::interpolate_indexed_transform(previous, current, weight, head));
ASSERT_TRUE(aurora::gx::interpolate_draw_transform(previous, current, weight, goggles));
const auto onHead = apply(head, goggleCorner), onGoggles = apply(goggles, goggleCorner);
for (size_t axis = 0; axis < 3; ++axis) EXPECT_NEAR(onGoggles[axis], onHead[axis], 0.05f) << weight;
}
// Camera and seat anchors are rigid; a sheared one still counts as a cut.
aurora::Mat3x4<float> anchor{};
EXPECT_FALSE(aurora::gx::interpolate_transform(previous, current, 0.5f, anchor));
}
TEST_F(GXFifoTest, VrCameraRebaseKeepsFarInstancesAndHeldParticlesContinuous) {
using aurora::Mat3x4;
using aurora::gfx::stereo_replay::compose_affine;
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(120); // Desktop and VR enabled together.
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
gxState().currentPnMtx = 0;
const Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const float angle = 0.08f, c = std::cos(angle), s = std::sin(angle);
const Mat3x4<float> currentView{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, currentView, identity, identity));
const auto draw = [&](const Mat3x4<float>& view, float x, bool particle = false) {
gxState().vtxDesc[GX_VA_POS] = particle ? GX_DIRECT : GX_INDEX16;
gxState().pnMtx[0].pos = particle ? identity : compose_affine(view, {{1, 0, 0, x}, {0, 1, 0, 0}, {0, 0, 1, -100000}});
if (particle) gxState().pnMtx[0].pos.m0[1] = -0.0f;
gxState().pnMtx[0].nrm = particle ? identity : view;
return aurora::gx::build_uniform(info, 0, {}, particle ? aurora::gx::FrameInterpolationDrawIdentity{200, 20, 1}
: aurora::gx::FrameInterpolationDrawIdentity{100, 10, 1}, true);
};
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
draw(identity, -2000);
draw(identity, 2000);
draw(identity, 0, true);
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
// All far instances move over the old 1500-unit gate just from camera yaw.
// Submission order also changes, and the right instance moves 30 world units.
const auto right = draw(currentView, 2030);
const auto left = draw(currentView, -2000);
const auto particle = draw(currentView, 0, true);
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
const auto read = [&](const auto& uniform, aurora::gfx::Range range) {
Mat3x4<float> matrix;
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
std::memcpy(static_cast<void*>(&matrix), bytes.data() + uniform.replayLayout.positionOffset, sizeof(matrix));
return matrix;
};
ASSERT_NE(right.previous.size, 0u);
ASSERT_NE(left.previous.size, 0u);
ASSERT_NE(particle.previous.size, 0u);
const auto previousRight = read(right, right.previous);
const auto expectedRight = compose_affine(currentView, {{1, 0, 0, 2000}, {0, 1, 0, 0}, {0, 0, 1, -100000}});
EXPECT_NEAR(previousRight.m0.w(), expectedRight.m0.w(), 0.01f);
EXPECT_NEAR(previousRight.m2.w(), expectedRight.m2.w(), 0.01f);
EXPECT_NEAR(read(left, left.previous).m0.w(), read(left, left.current).m0.w(), 0.01f);
EXPECT_FLOAT_EQ(read(particle, particle.previous).m0.x(), 1);
EXPECT_FLOAT_EQ(read(particle, particle.previous).m0.w(), 0); // No camera applied twice to baked vertices.
// Desktop retains its original guarded camera-space behavior.
ASSERT_NE(right.interpolated[0].size, 0u);
EXPECT_NEAR(read(right, right.interpolated[0]).m0.w(), read(right, right.current).m0.w(), 0.01f);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.matches, 3u);
EXPECT_EQ(diagnostics.preparedDraws, 3u);
EXPECT_EQ(diagnostics.rejectedDraws, 0u);
}
TEST_F(GXFifoTest, VrParticleCentersFollowMotionAcrossSortChangesAndCameraTurns) {
using aurora::Mat3x4;
using aurora::gx::offset_transform_origin;
using aurora::gfx::stereo_replay::compose_affine;
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(120);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const float angle = 0.08f, c = std::cos(angle), s = std::sin(angle);
const Mat3x4<float> view{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, view, identity, identity));
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
const auto center = [&](const Mat3x4<float>& camera, float x) {
const auto matrix = offset_transform_origin(camera, {x, 0, -5000});
return std::array<float, 3>{matrix.m0.w(), matrix.m1.w(), matrix.m2.w()};
};
const auto record = [&](const Mat3x4<float>& camera, float x, uint64_t geometry) {
return aurora::gx::build_uniform(info, 0, {}, {geometry, 7, 9, 0, geometry}, true, 1,
{center(camera, x), true});
};
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
record(identity, -600, 101);
record(identity, 600, 102);
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
// Vertex bytes change and transparent submission order reverses. Both draw
// matrices are identity, so matrix-only matching cannot identify the centers.
const auto right = record(view, 630, 201);
const auto left = record(view, -570, 202);
const auto newborn = record(view, 10000, 203);
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
const auto readCenter = [&](const auto& uniform, aurora::gfx::Range range) {
Mat3x4<float> matrix;
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
std::memcpy(&matrix, bytes.data() + uniform.replayLayout.positionOffset, sizeof(matrix));
EXPECT_FLOAT_EQ(matrix.m0.x(), 1); // Keep the current billboard's orientation.
EXPECT_FLOAT_EQ(matrix.m2.x(), 0);
return offset_transform_origin(matrix, uniform.replayLayout.vertexMotion.center);
};
for (const auto& [uniform, x] : {std::pair{right, 600.f}, std::pair{left, -600.f}}) {
ASSERT_NE(uniform.previous.size, 0u);
const auto previous = readCenter(uniform, uniform.previous);
EXPECT_NEAR(previous.m0.w(), center(view, x)[0], 0.002f);
EXPECT_NEAR(previous.m2.w(), center(view, x)[2], 0.002f);
const auto current = readCenter(uniform, uniform.current);
Mat3x4<float> half;
ASSERT_TRUE(aurora::gx::interpolate_draw_transform(previous, current, 0.5f, half));
EXPECT_NEAR(half.m0.w(), center(view, x + 15)[0], 0.002f);
// Simultaneous desktop interpolation uses its original camera endpoint.
const auto desktop = readCenter(uniform, uniform.interpolated[0]);
EXPECT_NEAR(desktop.m0.w(), (center(identity, x)[0] + center(view, x + 30)[0]) * 0.5f, 0.002f);
}
EXPECT_NEAR(readCenter(newborn, newborn.previous).m0.w(), center(view, 10000)[0], 0.002f);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.vertexMotionDraws, 2u);
}
namespace {
using Vec3f = std::array<float, 3>;
// One CPU-built particle quad the way nw4r::ef submits it: camera-space corners under
// an identity position matrix, all quads of an emitter sharing pipeline and texture.
aurora::gx::UniformRanges record_particle_quad(const aurora::gx::ShaderInfo& info, uint64_t geometry,
const Vec3f& center, const Vec3f& edge0, const Vec3f& edge1) {
return aurora::gx::build_uniform(info, 0, {}, {geometry, 7, 9, 0, geometry}, true, 1, {center, true},
{edge0, edge1});
}
// The centre a staged uniform draws the quad at.
Vec3f drawn_center(const aurora::gx::UniformRanges& uniform, aurora::gfx::Range range) {
aurora::Mat3x4<float> matrix;
const auto& bytes = aurora::gfx::testing::uniform_allocation(range.offset);
std::memcpy(static_cast<void*>(&matrix), bytes.data() + uniform.replayLayout.positionOffset, sizeof(matrix));
matrix = aurora::gx::offset_transform_origin(matrix, uniform.replayLayout.vertexMotion.center);
return {matrix.m0.w(), matrix.m1.w(), matrix.m2.w()};
}
Vec3f transform_point(const aurora::Mat3x4<float>& matrix, const Vec3f& point) {
return {matrix.m0[0] * point[0] + matrix.m0[1] * point[1] + matrix.m0[2] * point[2] + matrix.m0[3],
matrix.m1[0] * point[0] + matrix.m1[1] * point[1] + matrix.m1[2] * point[2] + matrix.m1[3],
matrix.m2[0] * point[0] + matrix.m2[1] * point[1] + matrix.m2[2] * point[2] + matrix.m2[3]};
}
bool same_point(const Vec3f& a, const Vec3f& b, float tolerance = 0.05f) {
return std::abs(a[0] - b[0]) <= tolerance && std::abs(a[1] - b[1]) <= tolerance &&
std::abs(a[2] - b[2]) <= tolerance;
}
struct ParticleInterpolationReset {
~ParticleInterpolationReset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
}
};
} // namespace
TEST_F(GXFifoTest, VrParticleQuadsPairByShapeWhereNearestCentresSwap) {
ParticleInterpolationReset reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().currentPnMtx = 0;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
// The camera flies 100 units forward and carries two streaks, each moving 60 along its
// own length. The one lying across the view ends nearer the other's old centre, so
// pairing centres alone swaps them and both sweep sideways.
const aurora::Mat3x4<float> forward{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 100}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, forward, identity, identity));
const Vec3f across{300, 0, 0}, upright{0, 300, 0}, thinX{12, 0, 0}, thinY{0, 12, 0};
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
record_particle_quad(info, 1, {-20, 0, -500}, across, thinY);
record_particle_quad(info, 2, {20, 0, -500}, upright, thinX);
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const auto lying = record_particle_quad(info, 3, {40, 0, -500}, across, thinY);
const auto standing = record_particle_quad(info, 4, {20, 60, -500}, upright, thinX);
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
ASSERT_NE(lying.previous.size, 0u);
ASSERT_NE(standing.previous.size, 0u);
// Each starts from its own old place, as the new camera position sees it.
EXPECT_TRUE(same_point(drawn_center(lying, lying.previous), {-20, 0, -400}));
EXPECT_TRUE(same_point(drawn_center(standing, standing.previous), {20, 0, -400}));
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.vertexMotionDraws, 2u);
EXPECT_EQ(diagnostics.vertexMotionHeld, 0u);
}
TEST_F(GXFifoTest, VrParticleQuadBornElsewhereDoesNotSweepFromOneThatDied) {
ParticleInterpolationReset reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().currentPnMtx = 0;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
const aurora::Mat3x4<float> forward{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 100}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, forward, identity, identity));
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
record_particle_quad(info, 1, {-100, 0, -500}, {300, 0, 0}, {0, 12, 0});
aurora::gx::finalize_frame_interpolation();
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
// That streak died; another one starts elsewhere. One quad on each side of the frame
// is no evidence that they are the same particle.
const auto newborn = record_particle_quad(info, 2, {100, 0, -500}, {0, 300, 0}, {12, 0, 0});
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
ASSERT_NE(newborn.previous.size, 0u);
EXPECT_TRUE(same_point(drawn_center(newborn, newborn.previous), {100, 0, -500}));
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.vertexMotionDraws, 0u);
EXPECT_EQ(diagnostics.vertexMotionHeld, 1u);
}
TEST_F(GXFifoTest, VrSpeedLineEmitterKeepsEachStreakOnItsOwnPath) {
// A deterministic stand-in for the boost speed lines (rk_koukasen in RKRace.breff):
// 12x300 streaks, each drawn as two crossed quads, two born per frame on a 90-unit
// ring, six-frame life, about 50 units per frame outwards and back, all carried by a
// camera that flies 100 units per frame while turning. Pairing nearest centres swapped
// about half of them and swept every newborn in from a streak that had just died.
ParticleInterpolationReset reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().currentPnMtx = 0;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = identity;
struct Streak {
uint32_t id, birth;
Vec3f origin, velocity;
};
struct Quad {
uint32_t streak, side;
Vec3f center, edge0, edge1;
};
uint32_t seed = 0x5eed1234u;
const auto random = [&seed] {
seed = seed * 1664525u + 1013904223u;
return static_cast<float>(seed >> 8) * (1.0f / 16777216.0f);
};
const auto quads_of = [](const Streak& streak, uint32_t frame) {
const float age = static_cast<float>(frame - streak.birth);
const auto& v = streak.velocity;
const float speed = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
const Vec3f axis{v[0] / speed, v[1] / speed, v[2] / speed};
// Two sides across the axis: the crossed planes of one streak.
const float across = std::sqrt(axis[0] * axis[0] + axis[2] * axis[2]);
const Vec3f side0{axis[2] / across, 0, -axis[0] / across};
const Vec3f side1{axis[1] * side0[2] - axis[2] * side0[1], axis[2] * side0[0] - axis[0] * side0[2],
axis[0] * side0[1] - axis[1] * side0[0]};
Vec3f center{};
for (size_t i = 0; i < 3; ++i) center[i] = streak.origin[i] + v[i] * age - axis[i] * 150;
std::array<Quad, 2> quads{};
for (uint32_t side = 0; side < 2; ++side) {
const auto& s = side == 0 ? side0 : side1;
quads[side] = {streak.id, side, center, {axis[0] * 300, axis[1] * 300, axis[2] * 300},
{s[0] * 12, s[1] * 12, s[2] * 12}};
}
return quads;
};
std::vector<Streak> streaks;
std::vector<Quad> previousQuads;
aurora::Mat3x4<float> previousView = identity;
Vec3f cameraPosition{};
float yaw = 0;
uint32_t nextId = 0, survivors = 0, moved = 0, held = 0, wrong = 0, newborns = 0, newbornsMoved = 0;
for (uint32_t frame = 0; frame < 90; ++frame) {
yaw += 0.01f;
const float c = std::cos(yaw), s = std::sin(yaw);
cameraPosition = {cameraPosition[0] - s * 100, 0, cameraPosition[2] - c * 100};
// Camera looks down -Z, rotated by yaw about +Y; the view is its inverse.
const aurora::Mat3x4<float> view{
{c, 0, -s, -(c * cameraPosition[0] - s * cameraPosition[2])},
{0, 1, 0, 0},
{s, 0, c, -(s * cameraPosition[0] + c * cameraPosition[2])}};
aurora::stereo::SceneCameraMotion motion;
const bool rebase = frame != 0 && motion.prepare(previousView, view, identity, identity);
ASSERT_TRUE(frame == 0 || rebase);
std::erase_if(streaks, [frame](const Streak& streak) { return frame - streak.birth >= 6; });
for (int born = 0; born < 2; ++born) {
const float angle = random() * 6.2831853f;
const float speed = 1.0f + (random() - 0.5f) * 0.46f;
streaks.push_back({nextId++, frame, {90 * std::cos(angle), 90 * std::sin(angle), -300},
{40 * speed * std::cos(angle), 40 * speed * std::sin(angle) + 10 * speed, 30 * speed}});
}
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
std::vector<Quad> quads;
std::vector<aurora::gx::UniformRanges> uniforms;
for (const auto& streak : streaks) {
for (const auto& quad : quads_of(streak, frame)) {
quads.push_back(quad);
uniforms.push_back(record_particle_quad(info, 1000 + quads.size() + frame * 100, quad.center,
quad.edge0, quad.edge1));
}
}
if (rebase)
aurora::gx::set_frame_interpolation_view_rebase(&motion.currentFromPrevious, &motion.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
if (rebase) {
for (size_t index = 0; index < quads.size(); ++index) {
const auto& quad = quads[index];
if (uniforms[index].previous.size == 0) continue;
const auto start = drawn_center(uniforms[index], uniforms[index].previous);
const auto before = std::find_if(previousQuads.begin(), previousQuads.end(), [&](const Quad& old) {
return old.streak == quad.streak && old.side == quad.side;
});
const bool heldHere = same_point(start, quad.center) ||
same_point(start, transform_point(motion.currentFromPrevious, quad.center));
if (before == previousQuads.end()) {
++newborns;
newbornsMoved += !heldHere;
continue;
}
++survivors;
if (heldHere) {
++held;
} else if (same_point(start, transform_point(motion.currentFromPrevious, before->center))) {
++moved;
} else {
++wrong;
}
}
}
previousQuads = std::move(quads);
previousView = view;
}
ASSERT_GT(survivors, 800u);
// Most streak quads keep moving along their own path...
EXPECT_GT(moved, survivors / 2);
// ...and almost none borrows another streak's (it was about half, plus every newborn).
EXPECT_LE(wrong * 50, survivors);
EXPECT_LE(newbornsMoved * 20, newborns);
std::printf("speed lines: %u survivor quads: %u moved, %u held, %u wrong; %u of %u newborn quads moved\n",
survivors, moved, held, wrong, newbornsMoved, newborns);
}
TEST_F(GXFifoTest, VrCyclicRigidMotionDoesNotBlendBackwardsAcrossReset) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
const auto info = aurora::gx::build_shader_info({});
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().vtxDesc[GX_VA_POS] = GX_INDEX16;
auto previousView = identity;
const auto record = [&](float phase, float yaw) {
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::reset_uniform_allocations();
const aurora::Mat3x4<float> view{{std::cos(yaw), 0, std::sin(yaw), 0}, {0, 1, 0, 0},
{-std::sin(yaw), 0, std::cos(yaw), 0}};
aurora::stereo::SceneCameraMotion camera;
EXPECT_TRUE(camera.prepare(previousView, view, identity, identity));
gxState().pnMtx[0].pos = aurora::gx::offset_transform_origin(view, {phase, 0, -5000});
gxState().pnMtx[0].nrm = view;
const auto result = aurora::gx::build_uniform(info, 0, {}, {11, 1, 2, 0, 11}, true);
aurora::gx::set_frame_interpolation_view_rebase(&camera.currentFromPrevious, &camera.previousFromCurrent);
aurora::gx::finalize_frame_interpolation();
previousView = view;
return result;
};
record(16, 0);
record(17, 0.02f);
record(18, 0.04f);
record(19, 0.06f);
AuroraFrameInterpolationDiagnostics before{}, after{};
aurora::gx::get_frame_interpolation_diagnostics(before);
const auto resetFrame = record(0, 0.08f);
aurora::gx::get_frame_interpolation_diagnostics(after);
EXPECT_EQ(after.animationWrapCuts, before.animationWrapCuts + 1);
const auto& current = aurora::gfx::testing::uniform_allocation(resetFrame.current.offset);
const auto& previous = aurora::gfx::testing::uniform_allocation(resetFrame.previous.offset);
EXPECT_EQ(current, previous); // Hold the new phase instead of reverse sweeping.
record(1, 0.10f);
record(0, 0.12f); // A normal same-speed direction change must still interpolate.
aurora::gx::get_frame_interpolation_diagnostics(after);
EXPECT_EQ(after.animationWrapCuts, before.animationWrapCuts + 1);
EXPECT_EQ(after.preparedDraws, 1u);
}
TEST(FrameInterpolationContract, RequiresStablePerspectiveDrawSequence) {
const auto resetInterpolation = [] {
aurora::gx::set_frame_interpolation_fps(0);
@@ -2269,6 +2895,103 @@ TEST_F(GXFifoTest, MergedDrawOffsetsCachedTopologyWithoutJoiningPrimitives) {
EXPECT_EQ(aurora::gfx::testing::last_pushed_indices(), (std::vector<u16>{3, 4, 5}));
}
TEST_F(GXFifoTest, VrDirectParticleQuadsRetainSeparateCentersInFifoAndRawDraws) {
struct Reset {
~Reset() {
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::begin_frame_interpolation();
}
} reset;
aurora::gx::detail::g_stereoFrameInterpolation.store(true);
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::use_real_vertex_format_helpers(true);
aurora::gfx::testing::use_draw_command_tracking(true);
gxState().projType = GX_PERSPECTIVE;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().vtxFmts[GX_VTXFMT0].attrs[GX_VA_POS] = {GX_POS_XYZ, GX_F32, 0};
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().stateDirty = true;
const auto makeVertices = [](float x) {
std::vector<uint8_t> vertices;
for (const auto& point : {std::array{x - 5, -5.f, -200.f}, std::array{x + 5, -5.f, -200.f},
std::array{x + 5, 5.f, -200.f}, std::array{x - 5, 5.f, -200.f}}) {
for (float component : point) {
uint32_t bits;
std::memcpy(&bits, &component, sizeof(bits));
for (int shift : {24, 16, 8, 0}) vertices.push_back(static_cast<uint8_t>(bits >> shift));
}
}
return vertices;
};
std::vector<uint8_t> commands;
for (float x : {20.f, 100.f}) {
commands.insert(commands.end(), {static_cast<uint8_t>(GX_QUADS), 0, 4});
const auto vertices = makeVertices(x);
commands.insert(commands.end(), vertices.begin(), vertices.end());
}
decode_fifo(commands);
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u);
auto* draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
EXPECT_TRUE(draw->uniformReplayLayout.vertexMotion.enabled);
EXPECT_EQ(draw->uniformReplayLayout.vertexMotion.center, (std::array{100.f, 0.f, -200.f}));
const auto raw = makeVertices(300);
ASSERT_TRUE(aurora::gx::fifo::submit_raw_draw(GX_QUADS, GX_VTXFMT0, raw.data(), 4, raw.size()));
draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
EXPECT_TRUE(draw->uniformReplayLayout.vertexMotion.enabled);
EXPECT_EQ(draw->uniformReplayLayout.vertexMotion.center, (std::array{300.f, 0.f, -200.f}));
// Turning VR interpolation off restores ordinary batching.
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
gxState().stateDirty = true;
decode_fifo(commands);
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 1u);
}
TEST_F(GXFifoTest, InterpolationOffRecordsNothingForParticlesOrMatching) {
// The Quest ships with VR interpolation off. Those frames must not pay for any
// of it: no draw is recorded, no particle quad is tracked, nothing is matched.
struct Reset {
~Reset() { aurora::gx::begin_frame_interpolation(); }
} reset;
aurora::gx::detail::g_stereoFrameInterpolation.store(false);
aurora::gx::set_frame_interpolation_fps(0);
aurora::gx::begin_frame_interpolation();
aurora::gfx::testing::use_real_vertex_format_helpers(true);
aurora::gfx::testing::use_draw_command_tracking(true);
gxState().projType = GX_PERSPECTIVE;
gxState().vtxDesc[GX_VA_POS] = GX_DIRECT;
gxState().vtxFmts[GX_VTXFMT0].attrs[GX_VA_POS] = {GX_POS_XYZ, GX_F32, 0};
gxState().pnMtx[0].pos = gxState().pnMtx[0].nrm = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
gxState().stateDirty = true;
std::vector<uint8_t> commands;
for (float x : {20.f, 100.f, 180.f}) {
commands.insert(commands.end(), {static_cast<uint8_t>(GX_QUADS), 0, 4});
for (const auto& point : {std::array{x - 5, -5.f, -200.f}, std::array{x + 5, -5.f, -200.f},
std::array{x + 5, 5.f, -200.f}, std::array{x - 5, 5.f, -200.f}}) {
for (float component : point) {
uint32_t bits;
std::memcpy(&bits, &component, sizeof(bits));
for (int shift : {24, 16, 8, 0}) commands.push_back(static_cast<uint8_t>(bits >> shift));
}
}
}
decode_fifo(commands);
aurora::gx::finalize_frame_interpolation();
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 2u); // Ordinary batching.
auto* draw = aurora::gfx::get_last_draw_command<aurora::gx::DrawData>();
ASSERT_NE(draw, nullptr);
EXPECT_FALSE(draw->uniformReplayLayout.vertexMotion.enabled);
AuroraFrameInterpolationDiagnostics diagnostics{};
aurora::gx::get_frame_interpolation_diagnostics(diagnostics);
EXPECT_EQ(diagnostics.candidates, 0u);
EXPECT_EQ(diagnostics.matches, 0u);
EXPECT_EQ(diagnostics.preparedDraws, 0u);
EXPECT_EQ(diagnostics.vertexMotionDraws, 0u);
EXPECT_EQ(diagnostics.vertexMotionHeld, 0u);
EXPECT_FALSE(aurora::gx::has_interpolated_frame());
}
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
+49 -12
View File
@@ -1,4 +1,4 @@
// Optional GPU smoke test: a 60 Hz GX producer with an independent 90 Hz
// Optional GPU smoke test: a 60 Hz GX producer with an independent headset-rate
// compositor. Exercises the real frame worker, eye replay and submission sink.
#include <aurora/aurora.h>
#include <aurora/gfx.h>
@@ -9,9 +9,11 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <thread>
@@ -56,6 +58,7 @@ static bool stop = false;
static uint64_t completed = 0;
static std::atomic_uint32_t submitted{0};
static uint64_t wakeLateness = 0, submitTime = 0, skippedTicks = 0, compositorFrames = 0;
static uint64_t headsetHz = 90, predictionLeadNanos = 40'000'000;
static bool Provide(uint32_t, AuroraStereoFrame* output, void*) {
std::lock_guard lock(packetMutex);
@@ -73,7 +76,7 @@ static void Submitted(const aurora::stereo::SinkFrame& frame, void*) noexcept {
packetCv.notify_all();
}
static void Log(AuroraLogLevel level, const char* module, const char* message, unsigned int length) {
if (level >= LOG_WARNING)
if (level >= LOG_WARNING || std::strstr(message, "[vr-motion]") != nullptr)
std::fprintf(stderr, "%s: %.*s\n", module, static_cast<int>(length), message);
}
@@ -81,8 +84,12 @@ int main(int argc, char** argv) {
// Extra distinct draws expose CPU uniform/replay costs that a single triangle
// cannot exercise. Keep the eye targets small to isolate that regression.
const unsigned drawCount = argc > 1 ? std::max(1, std::atoi(argv[1])) : 1;
const bool movingCamera = argc > 6 && std::atoi(argv[6]) != 0;
const bool particles = argc > 7 && std::atoi(argv[7]) != 0;
const int mode = argc < 3 ? 1 : std::clamp(std::atoi(argv[2]), 0, 2);
const bool indexed = argc > 3 && std::atoi(argv[3]) != 0;
const bool indexed = !particles && argc > 3 && std::atoi(argv[3]) != 0;
headsetHz = argc > 4 ? std::clamp(std::atoi(argv[4]), 60, 120) : 90;
predictionLeadNanos = argc > 5 ? std::clamp(std::atoi(argv[5]), 0, 100) * 1'000'000ull : 40'000'000;
std::filesystem::create_directories("stereo-smoke-cache");
AuroraConfig config{};
config.appName = "Aurora VR interpolation smoke";
@@ -95,11 +102,12 @@ int main(int argc, char** argv) {
config.windowPosX = -30000;
config.windowPosY = -30000;
config.logCallback = Log;
config.logLevel = LOG_WARNING;
config.logLevel = LOG_INFO;
config.xrInterop = true;
aurora_initialize(argc, argv, &config);
aurora_set_frame_interpolation_fps(0);
aurora_set_stereo_frame_interpolation(mode != 0);
aurora_set_stereo_motion_logging(true);
aurora_set_stereo_frame_provider(Provide, nullptr);
aurora::stereo::set_sink(Encode, Submitted, nullptr);
@@ -107,7 +115,7 @@ int main(int argc, char** argv) {
const auto start = Clock::now();
uint64_t slot = 1;
for (uint64_t token = 1;; ++token) {
const auto deadline = start + std::chrono::nanoseconds(slot * 1'000'000'000 / 90);
const auto deadline = start + std::chrono::nanoseconds(slot * 1'000'000'000 / headsetHz);
WaitUntil(deadline);
const auto woke = Clock::now();
wakeLateness +=
@@ -119,11 +127,11 @@ int main(int argc, char** argv) {
packet = {};
packet.frameToken = token;
packet.contentTag = 42;
// Wake to render the next display tick, as xrWaitFrame does, rather
// than announcing an image whose display deadline has already passed.
// A runtime can predict multiple frames ahead. This must not exhaust
// scene history while the producer and compositor maintain their rates.
packet.displayTimeNanos =
std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count() +
1'000'000'000 / 90;
predictionLeadNanos;
for (auto& eye : packet.eyes) {
eye.width = 160;
eye.height = 120;
@@ -146,7 +154,7 @@ int main(int argc, char** argv) {
const auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now() - start).count();
submitTime += std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now() - woke).count();
++compositorFrames;
const auto nextSlot = std::max(slot + 1, static_cast<uint64_t>(elapsed) * 90 / 1'000'000'000);
const auto nextSlot = std::max(slot + 1, static_cast<uint64_t>(elapsed) * headsetHz / 1'000'000'000);
skippedTicks += nextSlot - slot - 1;
slot = nextSlot;
}
@@ -178,6 +186,19 @@ int main(int argc, char** argv) {
std::chrono::duration_cast<std::chrono::nanoseconds>(boundary.time_since_epoch()).count(), 16'666'667);
Mtx44 projection{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, -1, -1}, {0, 0, -1, 0}};
Mtx transform{{1, 0, 0, static_cast<float>(frame % 60) * 0.01f}, {0, 1, 0, 0}, {0, 0, 1, -3}};
const float yaw = movingCamera ? std::sin(static_cast<float>(frame) * 0.08f) * 0.5f : 0;
Mtx sceneView{{std::cos(yaw), 0, std::sin(yaw), 0}, {0, 1, 0, 0}, {-std::sin(yaw), 0, std::cos(yaw), 0}};
if (movingCamera) {
transform[2][3] = -100000;
aurora_set_stereo_scene_view(&sceneView[0][0]);
}
const auto applyView = [&](Mtx viewed) {
for (unsigned row = 0; row < 3; ++row)
for (unsigned col = 0; col < 4; ++col)
viewed[row][col] = (col == 3 ? sceneView[row][3] : 0.f) +
sceneView[row][0] * transform[0][col] + sceneView[row][1] * transform[1][col] +
sceneView[row][2] * transform[2][col];
};
GXSetProjection(projection, GX_PERSPECTIVE);
GXSetCurrentMtx(GX_PNMTX0);
GXSetViewport(0, 0, 160, 120, 0, 1);
@@ -195,12 +216,28 @@ int main(int argc, char** argv) {
// Keep palette triangles small to limit fill cost during uniform stress.
const float extent = indexed ? 0.02f : 1.0f;
if (indexed) {
Mtx viewed;
applyView(viewed);
for (unsigned matrix = 0; matrix < 10; ++matrix)
GXLoadPosMtxImm(transform, matrix * 3);
GXLoadPosMtxImm(viewed, matrix * 3);
}
for (unsigned draw = 0; draw < drawCount; ++draw) {
transform[1][3] = static_cast<float>(draw % 20) * 0.01f;
GXLoadPosMtxImm(transform, GX_PNMTX0);
Mtx viewed;
applyView(viewed);
if (particles) {
// CPU-authored billboard centers, identity XF, current vertex shapes.
// Exercise the actual FIFO decoder, seal and late eye-uniform path.
Mtx identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
if (draw == 0) GXLoadPosMtxImm(identity, GX_PNMTX0);
GXBegin(GX_QUADS, GX_VTXFMT0, 4);
for (const auto& corner : {std::pair{-0.01f, -0.01f}, std::pair{0.01f, -0.01f},
std::pair{0.01f, 0.01f}, std::pair{-0.01f, 0.01f}})
GXPosition3f32(viewed[0][3] + corner.first, viewed[1][3] + corner.second, viewed[2][3]);
GXEnd();
continue;
}
GXLoadPosMtxImm(viewed, GX_PNMTX0);
GXBegin(GX_TRIANGLES, GX_VTXFMT0, indexed ? 30 : 3);
for (unsigned matrix = 0; matrix < (indexed ? 10u : 1u); ++matrix) {
if (indexed)
@@ -248,6 +285,6 @@ int main(int argc, char** argv) {
measuredSubmissions, elapsed, fps);
aurora_shutdown();
// More headset submissions must not come at the expense of simulation speed.
const double target = mode == 2 ? 75 : mode == 1 ? 90 : 60;
const double target = mode == 2 ? (headsetHz + 60) / 2.0 : mode == 1 ? headsetHz : 60;
return 240 / elapsed > 55 && fps > target - 5 && fps < target + 5 ? 0 : 1;
}
@@ -1,6 +1,60 @@
#include "stereo_interpolation.hpp"
#include "scene_camera.hpp"
#include <gtest/gtest.h>
#include <cmath>
#include <limits>
TEST(StereoInterpolation, SeparatesCameraFromHeldGeometryAndFirstPersonAnchor) {
using aurora::Mat3x4;
using aurora::gfx::stereo_replay::compose_affine;
const Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
const float angle = 0.08f;
const float c = std::cos(angle), s = std::sin(angle);
const Mat3x4<float> currentView{{c, 0, s, 0}, {0, 1, 0, 0}, {-s, 0, c, 0}};
const Mat3x4<float> object{{1, 0, 0, 1000}, {0, 1, 0, 0}, {0, 0, 1, -100000}};
const auto currentObject = compose_affine(currentView, object);
aurora::stereo::SceneCameraMotion motion;
for (bool firstPerson : {false, true}) {
// A moving seat is distinct from the game's chase camera. Both must be
// sampled once, and only once, even for geometry with no usable history.
auto previousAnchor = identity, currentAnchor = identity;
if (firstPerson) {
previousAnchor.m1[3] = 200;
currentAnchor.m1[3] = 230;
}
ASSERT_TRUE(motion.prepare(identity, currentView, previousAnchor, currentAnchor));
for (float weight : {0.f, 1.f / 3, 2.f / 3, 1.f}) {
Mat3x4<float> sampledAnchor{}, expectedPose{}, expectedView{};
ASSERT_TRUE(motion.sample(weight, sampledAnchor));
ASSERT_TRUE(aurora::gx::interpolate_transform(motion.previousPose, motion.currentPose, weight, expectedPose));
ASSERT_TRUE(aurora::stereo::inverse_rigid_view(expectedPose, expectedView));
const auto expected = compose_affine(expectedView, object);
// Identical math covers held particle vertices baked into current view
// space and an unmatched/rejected billboard using its current matrix.
const auto actual = compose_affine(sampledAnchor, currentObject);
EXPECT_NEAR(actual.m0.w(), expected.m0.w(), 0.02f);
EXPECT_NEAR(actual.m1.w(), expected.m1.w(), 0.02f);
EXPECT_NEAR(actual.m2.w(), expected.m2.w(), 0.02f);
}
}
}
TEST(StereoInterpolation, CameraCutsAndMalformedViewsDisableCameraSeparation) {
const aurora::Mat3x4<float> identity{{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}};
aurora::stereo::SceneCameraMotion motion;
ASSERT_TRUE(motion.prepare(identity, identity, identity, identity));
auto cut = identity;
cut.m0[3] = 2000;
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
EXPECT_FALSE(motion.active);
cut = identity;
cut.m0[0] = 2;
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
cut.m0[0] = std::numeric_limits<float>::quiet_NaN();
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
cut = {{-1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, -1, 0}};
EXPECT_FALSE(motion.prepare(identity, cut, identity, identity));
}
TEST(StereoInterpolation, ContinuousMotionAcross60HzScenesAtHeadsetRates) {
constexpr uint64_t interval = 16'666'667;
@@ -30,3 +84,100 @@ TEST(StereoInterpolation, MissingTimingAndStallsDoNotExtrapolate) {
EXPECT_FLOAT_EQ(interpolation_weight(105, 100, 10), 0.5);
EXPECT_FLOAT_EQ(interpolation_weight(500, 100, 10), 1);
}
TEST(StereoInterpolation, PlaybackCadenceDoesNotFollowFutureHeadPrediction) {
constexpr uint64_t start = 1'000'000'000, interval = 16'666'667;
for (uint64_t hz : {72u, 90u, 120u}) {
// The producer's desktop presentation boundary can be ahead of, or behind,
// its actual seal. Neither offset belongs in headset scene playback.
for (int64_t scheduleOffset : {-5'000'000, 0, 7'000'000}) {
const uint64_t origin = start + scheduleOffset;
aurora::stereo::ScenePlaybackClock clock;
clock.begin_scene(origin, start, false);
uint64_t sealedScene = 0;
double previousPosition = 0;
uint32_t oldClampedSamples = 0;
for (uint64_t sample = 1; sample <= hz; ++sample) {
const uint64_t now = start + sample * 1'000'000'000 / hz;
const uint64_t scene = (now - start) / interval;
const uint64_t boundary = origin + scene * interval;
if (scene != sealedScene) {
// Seal jitter must not re-phase the entire playback clock.
clock.begin_scene(boundary, start + scene * interval + (scene % 3) * 100'000, true);
sealedScene = scene;
}
// The captured Virtual Desktop session predicted 37-65 ms ahead.
const uint64_t displayTime = now + (37 + sample % 29) * 1'000'000;
oldClampedSamples += aurora::stereo::interpolation_weight(displayTime, boundary, interval) == 1.0f;
const float weight = aurora::stereo::interpolation_weight(clock.sample_time(now), boundary, interval);
const double position = static_cast<double>(scene) + weight;
if (sample > 1)
EXPECT_NEAR(position - previousPosition, 1'000'000'000.0 / hz / interval, 1e-5);
previousPosition = position;
}
EXPECT_EQ(oldClampedSamples, hz); // Regression reproduces the old all-current result.
}
}
}
TEST(StereoInterpolation, PlaybackReanchorsOnCutsButNeverExtrapolatesAStalledScene) {
aurora::stereo::ScenePlaybackClock clock;
EXPECT_EQ(clock.sample_time(100), 0u);
clock.begin_scene(1000, 100, false);
EXPECT_EQ(clock.sample_time(105), 1005u);
clock.begin_scene(1010, 113, true);
EXPECT_EQ(clock.sample_time(115), 1015u); // Seal latency is not a new clock origin.
EXPECT_FLOAT_EQ(aurora::stereo::interpolation_weight(clock.sample_time(500), 1010, 10), 1);
clock.begin_scene(2000, 500, false); // Stall recovery / scene change.
EXPECT_EQ(clock.sample_time(505), 2005u);
clock.begin_scene(100, 510, true); // Reset producer schedule.
EXPECT_EQ(clock.sample_time(515), 105u);
clock.begin_scene(110, 20, true); // Reset host clock.
EXPECT_EQ(clock.sample_time(25), 115u);
EXPECT_EQ(clock.sample_time(19), 0u);
}
TEST(StereoInterpolation, EarlierProductionAfterWarmupDoesNotClampToPreviousEndpoints) {
aurora::stereo::ScenePlaybackClock clock;
clock.begin_scene(1000, 100, false);
clock.begin_scene(1010, 106, true); // Producer sheds four time units of warm-up latency.
EXPECT_EQ(clock.sample_time(106), 1010u);
EXPECT_EQ(clock.sample_time(111), 1015u);
clock.begin_scene(1020, 118, true); // A subsequent late seal must not move the clock back.
EXPECT_EQ(clock.sample_time(118), 1022u);
EXPECT_FLOAT_EQ(aurora::stereo::interpolation_weight(clock.sample_time(118), 1020, 10), 0.2f);
}
TEST(StereoInterpolation, MotionDiagnosticsDistinguishCadenceFromSubmissionCount) {
aurora::stereo::MotionSamples samples;
constexpr uint64_t boundary = 1'000'000'000, interval = 16'666'667;
const auto record = [&](uint64_t display, uint64_t sceneBoundary, bool continuous = true) {
samples.record(display, sceneBoundary, interval, continuous,
aurora::stereo::interpolation_weight(display, sceneBoundary, interval));
};
record(boundary, boundary);
record(boundary + interval / 2, boundary);
record(boundary + interval, boundary);
// A future display deadline outruns the retained scene; another submission
// cannot advance its motion, even though it can apply a fresh head pose.
record(boundary + 2 * interval, boundary);
record(boundary + 2 * interval, boundary + interval);
EXPECT_EQ(samples.samples, 5u);
EXPECT_EQ(samples.blended, 1u);
EXPECT_EQ(samples.atPrevious, 1u);
EXPECT_EQ(samples.atCurrent, 3u);
EXPECT_EQ(samples.repeated, 1u);
EXPECT_EQ(samples.backwards, 0u);
EXPECT_EQ(samples.minStep, 0u);
EXPECT_EQ(samples.maxStep, interval);
samples.clear_window();
record(boundary + 2 * interval, boundary + interval);
EXPECT_EQ(samples.samples, 1u);
EXPECT_EQ(samples.repeated, 1u); // Preserve cadence across reporting windows.
record(boundary + interval / 2, boundary);
EXPECT_EQ(samples.backwards, 1u);
record(boundary, boundary, false);
record(boundary, boundary);
EXPECT_EQ(samples.discontinuous, 1u);
EXPECT_EQ(samples.backwards, 1u); // A camera cut starts a new sequence.
}