Add VR object culling functionality

- Introduced a new configuration option for object culling in VR, allowing the game to hide objects outside the camera's view.
- Implemented native replacements for the Mario Kart functions responsible for scene culling, ensuring accurate behavior in VR.
- Added a new header file `mkw_vr_culling.h` to define the culling logic and structures.
- Created `mkw_vr_culling.cpp` to implement the culling logic, including frustum intersection checks and screen info updates.
- Updated runtime configuration to include the new object culling option, with appropriate getters and setters.
- Enhanced the settings overlay to allow users to toggle object culling in VR.
- Added tests in `vr_culling_tests.cpp` to validate the frustum intersection logic and ensure compliance with original behavior.
- Updated CMake files to include new source files and tests.
This commit is contained in:
iChris4 committed 2026-09-29 01:53:54 +02:00
1 parent 3c20f677eb
commit e2c4eb3c8c
10 files changed
+718 -7

No files matched your search

+31 -5
View File
@@ -60,6 +60,7 @@ first_person_hidden_model = 0
first_person_rotation = "yaw_pitch" first_person_rotation = "yaw_pitch"
steering_wheel = true steering_wheel = true
native_steering_wheel = true native_steering_wheel = true
object_culling = true
hand_steering = true hand_steering = true
performance_level = "boost" performance_level = "boost"
``` ```
@@ -493,10 +494,35 @@ the frame and when cockpit view stops. This changes rendering only, without modi
or the item's behaviour. `mkw_vr_bullet_bill_tests` and Aurora's `HiddenModelTest` cover body/arm or the item's behaviour. `mkw_vr_bullet_bill_tests` and Aurora's `HiddenModelTest` cover body/arm
selection, malformed models, instance matching, and both FIFO and raw draw paths. selection, malformed models, instance matching, and both FIFO and raw draw paths.
One limitation is worth knowing: Mario Kart still culls the scene from its own chase camera, so a As with the rest of the race instrumentation, the object offsets this reads are specific to the
wide head turn in first person can reveal the edge of what the game decided to draw. As with the project's supported PAL `RMCP01` translation.
rest of the race instrumentation, the object offsets this reads are specific to the project's
supported PAL `RMCP01` translation. ### Object culling
Mario Kart hides what its own chase camera cannot see, and that camera does not know where the
headset is looking. Turn your head far enough in an immersive race, or look over your shoulder
in first person, and karts, characters and course objects are simply missing until the game
camera catches up; the race intro's pan shows it too, since the other racers are culled from the
intro camera's narrow view. `object_culling = false` (F10 > Camera > Object culling, also on the
headset settings panel's Camera tab) draws them anyway, and takes effect immediately. The
default, `true`, leaves the game's culling in force.
The game culls in two places, and the switch covers both. NW4R's scene gather tests each model's
bounding box against the camera frustum (`nw4r::math::FRUSTUM::IntersectAABB_Ex`); with culling
off that test reports every box as partially inside. Mario Kart's own `ClipInfoMgr` then tests
each kart, item and object against per-screen side planes derived from the camera
(`ClipInfoMgr::UpdateScreenInfo`); with culling off those planes carry zero normals, which no
model can be beyond. Both functions are replaced by faithful native reimplementations in
`runtime/src/vr/mkw_vr_culling.cpp`, so with culling on they compute exactly what the translated
originals did. `mkw_vr_culling_tests` covers the frustum test. What stays as the game decides
it: the draw distance, the course's area-based clipping groups, and every gameplay rule, since
none of this changes physics or object updates.
The setting only takes effect while VR is enabled (a session that fell back to the desktop for
want of a headset included), and not in the Flat screen race view, which shows the game camera's
own view. It costs GPU time: every model the camera would have dropped is drawn for
both eyes. On the Quest, where the eye passes are geometry-bound, leave
it on unless the missing racers bother you more than the frame time.
## Steering wheel and hand steering ## Steering wheel and hand steering
@@ -1149,7 +1175,7 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen
- The Quest build (`android/`, `docs/quest-port.md`) runs on a Quest 3 through menus and races. - The Quest build (`android/`, `docs/quest-port.md`) runs on a Quest 3 through menus and races.
Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging
is not implemented. is not implemented.
- Scene-specific comfort options, culling fixes and replay/spectator classification are future work. - Scene-specific comfort options and replay/spectator classification are future work.
- Hand steering works on a Quest 3 (2026-09-22): the kart's own wheel animated (228 draws a frame, - Hand steering works on a Quest 3 (2026-09-22): the kart's own wheel animated (228 draws a frame,
the race camera's view matching the scene's exactly) and the wheel can be grabbed and turned. In the race camera's view matching the scene's exactly) and the wheel can be grabbed and turned. In
that race the driver's eye was never calibrated, so the fallback placed the wheel centre about that race the driver's eye was never calibrated, so the fallback placed the wheel centre about
+1 -1
View File
@@ -418,7 +418,7 @@ add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests)
# ported from heurazy's mario-kart-wii-VR-port, and tracked hands (grasp, bare-hand # ported from heurazy's mario-kart-wii-VR-port, and tracked hands (grasp, bare-hand
# buttons, flick). All header-only. # buttons, flick). All header-only.
foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_bullet_bill_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_bullet_bill_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests
mkw_vr_hand_tracking_tests) mkw_vr_hand_tracking_tests mkw_vr_culling_tests)
string(REGEX REPLACE "^mkw_" "" test_source "${test_name}") string(REGEX REPLACE "^mkw_" "" test_source "${test_name}")
add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp") add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp")
target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
+3 -1
View File
@@ -159,7 +159,9 @@ endforeach()
# the same contraction/rounding policy as translated PPC shards. # the same contraction/rounding policy as translated PPC shards.
set(MKW_PPC_SEMANTIC_RUNTIME_SOURCES set(MKW_PPC_SEMANTIC_RUNTIME_SOURCES
"${MKW_RUNTIME_SOURCE_DIR}/src/ppc_helpers.cpp" "${MKW_RUNTIME_SOURCE_DIR}/src/ppc_helpers.cpp"
"${MKW_RUNTIME_SOURCE_DIR}/src/fpu_helpers.cpp") "${MKW_RUNTIME_SOURCE_DIR}/src/fpu_helpers.cpp"
# Native reimplementations of the game's culling math (VR object culling).
"${MKW_RUNTIME_SOURCE_DIR}/src/vr/mkw_vr_culling.cpp")
set_source_files_properties(${MKW_PPC_SEMANTIC_RUNTIME_SOURCES} PROPERTIES set_source_files_properties(${MKW_PPC_SEMANTIC_RUNTIME_SOURCES} PROPERTIES
SKIP_UNITY_BUILD_INCLUSION ON SKIP_UNITY_BUILD_INCLUSION ON
SKIP_PRECOMPILE_HEADERS ON SKIP_PRECOMPILE_HEADERS ON
+19
View File
@@ -76,6 +76,7 @@ struct RuntimeUserConfig {
std::optional<float> vrCockpitUnitsPerMeter; std::optional<float> vrCockpitUnitsPerMeter;
std::optional<bool> vrSteeringWheel; std::optional<bool> vrSteeringWheel;
std::optional<bool> vrNativeSteeringWheel; std::optional<bool> vrNativeSteeringWheel;
std::optional<bool> vrObjectCulling;
std::optional<bool> vrHandSteering; std::optional<bool> vrHandSteering;
std::optional<bool> vrHandTracking; std::optional<bool> vrHandTracking;
std::optional<float> vrWheelKartDegrees; std::optional<float> vrWheelKartDegrees;
@@ -235,6 +236,10 @@ inline constexpr float kVrCockpitUnitsPerMeterMax = 400.0f;
inline constexpr bool kVrSteeringWheelDefault = true; inline constexpr bool kVrSteeringWheelDefault = true;
inline constexpr bool kVrNativeSteeringWheelDefault = true; inline constexpr bool kVrNativeSteeringWheelDefault = true;
inline constexpr bool kVrHandSteeringDefault = true; inline constexpr bool kVrHandSteeringDefault = true;
// The game hides karts and objects its own camera cannot see, which a head
// turn in VR reveals. object_culling false draws them anyway (see
// vr/mkw_vr_culling.h); it only takes effect while VR is enabled.
inline constexpr bool kVrObjectCullingDefault = true;
// The cockpit hands follow the headset's hand tracking (the controllers' touch // The cockpit hands follow the headset's hand tracking (the controllers' touch
// sensors while they are held, the cameras once they are put down, when bare // sensors while they are held, the cameras once they are put down, when bare
// hands also drive). Opt-in, and only offered on the Quest for now; the // hands also drive). Opt-in, and only offered on the Quest for now; the
@@ -577,6 +582,10 @@ inline void EnsureConfigFile() {
"# own model; false draws a separate VR wheel instead.\n" "# own model; false draws a separate VR wheel instead.\n"
"steering_wheel = true\n" "steering_wheel = true\n"
"native_steering_wheel = true\n" "native_steering_wheel = true\n"
"# The game hides karts and objects its own camera cannot see.\n"
"# object_culling = false draws them anyway, so a head turn or a\n"
"# look over the shoulder shows them; it costs GPU time.\n"
"object_culling = true\n"
"# Hand steering (by heurazy): squeeze a grip near the wheel or\n" "# Hand steering (by heurazy): squeeze a grip near the wheel or\n"
"# handlebar to take hold of it with the tracked controllers, and\n" "# handlebar to take hold of it with the tracked controllers, and\n"
"# turn it to steer. Releasing both grips gives steering back to the\n" "# turn it to steer. Releasing both grips gives steering back to the\n"
@@ -859,6 +868,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
readRangedFloat("cockpit_units_per_meter", kVrCockpitUnitsPerMeterMin, kVrCockpitUnitsPerMeterMax); readRangedFloat("cockpit_units_per_meter", kVrCockpitUnitsPerMeterMin, kVrCockpitUnitsPerMeterMax);
config.vrSteeringWheel = FindConfigValue<bool>(document, "vr", "steering_wheel"); config.vrSteeringWheel = FindConfigValue<bool>(document, "vr", "steering_wheel");
config.vrNativeSteeringWheel = FindConfigValue<bool>(document, "vr", "native_steering_wheel"); config.vrNativeSteeringWheel = FindConfigValue<bool>(document, "vr", "native_steering_wheel");
config.vrObjectCulling = FindConfigValue<bool>(document, "vr", "object_culling");
config.vrHandSteering = FindConfigValue<bool>(document, "vr", "hand_steering"); config.vrHandSteering = FindConfigValue<bool>(document, "vr", "hand_steering");
config.vrHandTracking = FindConfigValue<bool>(document, "vr", "hand_tracking"); config.vrHandTracking = FindConfigValue<bool>(document, "vr", "hand_tracking");
config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax); config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
@@ -1250,6 +1260,11 @@ inline bool SetVrNativeSteeringWheel(bool value) {
return WriteSetting("vr", "native_steering_wheel", value ? "true" : "false"); return WriteSetting("vr", "native_steering_wheel", value ? "true" : "false");
} }
inline bool SetVrObjectCulling(bool value) {
Mutable().vrObjectCulling = value;
return WriteSetting("vr", "object_culling", value ? "true" : "false");
}
inline bool SetVrHandSteering(bool value) { inline bool SetVrHandSteering(bool value) {
Mutable().vrHandSteering = value; Mutable().vrHandSteering = value;
return WriteSetting("vr", "hand_steering", value ? "true" : "false"); return WriteSetting("vr", "hand_steering", value ? "true" : "false");
@@ -1727,6 +1742,10 @@ inline bool VrNativeSteeringWheel(bool fallback = kVrNativeSteeringWheelDefault)
return Get().vrNativeSteeringWheel.value_or(fallback); return Get().vrNativeSteeringWheel.value_or(fallback);
} }
inline bool VrObjectCulling(bool fallback = kVrObjectCullingDefault) {
return Get().vrObjectCulling.value_or(fallback);
}
inline bool VrHandSteering(bool fallback = kVrHandSteeringDefault) { inline bool VrHandSteering(bool fallback = kVrHandSteeringDefault) {
return Get().vrHandSteering.value_or(fallback); return Get().vrHandSteering.value_or(fallback);
} }
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
// Mario Kart's own object culling, and the VR switch that turns it off
// (vr.object_culling = false; F10 > Camera, or the headset settings panel's
// Camera tab). It applies while VR is enabled, except in the Flat screen race
// view.
//
// The game hides what its chase camera cannot see in two places, both keyed
// to the game camera and not to the headset, so in VR a wide head turn or a
// first-person look over the shoulder reveals missing karts and characters:
//
// 1. nw4r::g3d::ScnObjGather::Add tests every scene object's bounding box
// against the camera frustum through nw4r::math::FRUSTUM::IntersectAABB_Ex
// (0x80086610, its only caller) and drops the ones outside.
// 2. ClipInfoMgr::Update tests every ClipInfo (karts, objects, items) against
// the per-screen ClipScreenInfo that ClipInfoMgr::UpdateScreenInfo
// (0x8078707C) derives from the camera: a draw distance, the area groups
// and six side-plane normals. Models outside a plane by more than their
// radius are flagged clipped and ModelDirector hides them.
//
// mkw_vr_culling.cpp replaces both functions natively with faithful
// reimplementations (the translator drops the translated body of a natively
// registered address, so there is no original left to fall through to). With
// culling off, the frustum test reports every box as partially inside and the
// screen info carries zero plane normals, which no model can be beyond. The
// draw-distance and area-group clipping stay as the game decides them: they do
// not depend on where the player looks.
//
// The pure parts live here so runtime/tests/vr_culling_tests.cpp can check
// them without guest memory. Every offset is specific to PAL RMCP01.
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace mkw::vr {
// nw4r::math::AABB: minimum corner, then maximum corner.
struct CullingAabb {
float min[3];
float max[3];
};
// One nw4r::math::PLANE as FRUSTUM stores its six: an outward normal and a
// distance, so a point is outside when dot(normal, point) + distance > 0.
struct CullingPlane {
float normal[3];
float distance;
};
// The two parts of nw4r::math::FRUSTUM that IntersectAABB_Ex reads: the
// frustum's own bounding box (+0x78) and its six planes (+0x90, 16 bytes each).
struct CullingFrustum {
CullingAabb box;
CullingPlane planes[6];
};
inline constexpr uint32_t kFrustumBoxOffset = 0x78u;
inline constexpr uint32_t kFrustumPlanesOffset = 0x90u;
inline constexpr uint32_t kFrustumPlaneStride = 0x10u;
inline constexpr size_t kFrustumPlaneCount = 6;
// IntersectAABB_Ex's return value; ScnObjGather::Add keeps the object for
// anything but Outside.
enum class FrustumAabbResult : int32_t {
Outside = 0,
Inside = 1,
Partial = 2,
};
// The PowerPC compares behind IntersectAABB_Ex's branches (fcmpo). Each is
// true only for an ordered result, so a NaN operand makes every one of them
// false, and whether that rejects or keeps a box depends on which way the
// original branches. The NaN test works on the bits, so it holds even in a
// translation unit built with -ffast-math, where a plain float compare may
// assume NaN never occurs.
inline bool CullingIsNan(float value) noexcept {
uint32_t bits;
std::memcpy(&bits, &value, sizeof(bits));
return (bits & 0x7FFFFFFFu) > 0x7F800000u;
}
inline bool CullingOrderedGreater(float a, float b) noexcept {
return !CullingIsNan(a) && !CullingIsNan(b) && a > b;
}
inline bool CullingOrderedLessOrEqual(float a, float b) noexcept {
return !CullingIsNan(a) && !CullingIsNan(b) && a <= b;
}
inline bool CullingOrderedGreaterOrEqual(float a, float b) noexcept {
return !CullingIsNan(a) && !CullingIsNan(b) && a >= b;
}
#if defined(__clang__)
// The runtime's unity batches build with -ffast-math. mkw_vr_culling.cpp, the
// only caller, is built with the translated code's precise options instead
// (cmake/PublicProducts.cmake); these pragmas keep the definition's rounding
// exact wherever else the header is compiled.
#pragma float_control(push)
#pragma float_control(precise, on)
#endif
// nw4r::math::FRUSTUM::IntersectAABB_Ex, operation for operation. Every
// arithmetic step is a separate single-precision statement, and the one fused
// multiply-add (ps_madd, which the translated code runs as one fused
// single-precision FMA per lane) is an explicit fmaf, so the result is
// bit-identical to the translated original whatever the host's contraction
// setting. Each test names the branch the original takes.
inline FrustumAabbResult FrustumIntersectAabb(const CullingFrustum& frustum,
const CullingAabb& aabb) noexcept {
#if defined(__clang__)
#pragma clang fp contract(off)
#endif
const CullingAabb& box = frustum.box;
// The frustum's bounding box first: bgt rejects, except for the last
// compare, where ble keeps (so a NaN there rejects).
for (size_t axis = 0; axis < 3; ++axis) {
if (CullingOrderedGreater(aabb.min[axis], box.max[axis])) {
return FrustumAabbResult::Outside;
}
if (axis < 2) {
if (CullingOrderedGreater(box.min[axis], aabb.max[axis])) {
return FrustumAabbResult::Outside;
}
} else if (!CullingOrderedLessOrEqual(box.min[axis], aabb.max[axis])) {
return FrustumAabbResult::Outside;
}
}
FrustumAabbResult result = FrustumAabbResult::Inside;
for (size_t p = 0; p < kFrustumPlaneCount; ++p) {
const CullingPlane& plane = frustum.planes[p];
// The corner the normal points away from (the box's least value along
// the plane) and the corner it points at (its greatest). A NaN normal
// component fails the ordered >= and takes the second choice.
float least[3];
float greatest[3];
for (size_t axis = 0; axis < 3; ++axis) {
const bool non_negative = CullingOrderedGreaterOrEqual(plane.normal[axis], 0.0f);
least[axis] = non_negative ? aabb.min[axis] : aabb.max[axis];
greatest[axis] = non_negative ? aabb.max[axis] : aabb.min[axis];
}
// ps_mul (y, z), ps_madd (x onto y), ps_sum0 (+ z), fadds (+ distance).
const auto signed_distance = [&plane](const float corner[3]) noexcept {
const float yy = plane.normal[1] * corner[1];
const float zz = plane.normal[2] * corner[2];
const float xy = std::fmaf(plane.normal[0], corner[0], yy);
const float sum = xy + zz;
return plane.distance + sum;
};
// Both branches are ble over "keep going": anything but an ordered
// <= 0, NaN included, rejects the box or marks it partial.
if (!CullingOrderedLessOrEqual(signed_distance(least), 0.0f)) {
return FrustumAabbResult::Outside;
}
if (!CullingOrderedLessOrEqual(signed_distance(greatest), 0.0f)) {
result = FrustumAabbResult::Partial;
}
}
return result;
}
#if defined(__clang__)
#pragma float_control(pop)
#endif
// ClipScreenInfo, 0x60 bytes per screen, as ClipInfoMgr::UpdateScreenInfo
// fills it and ClipInfoMgr::Update reads it.
inline constexpr uint32_t kClipScreenInfoBytes = 0x60u;
// The camera position (+0x00) and the camera's own distance value (+0x0C).
inline constexpr uint32_t kClipScreenCameraOffset = 0x00u;
inline constexpr uint32_t kClipScreenDistanceOffset = 0x0Cu;
// Six side-plane normals: left and right, their widened copies, top and
// bottom, six vectors from +0x10 to +0x57.
inline constexpr uint32_t kClipScreenPlanesOffset = 0x10u;
inline constexpr uint32_t kClipScreenPlanesBytes = 0x48u;
// The squared draw-distance scale (+0x58) and the area 8/9 group bits (+0x5C).
inline constexpr uint32_t kClipScreenDrawScaleOffset = 0x58u;
inline constexpr uint32_t kClipScreenAreaGroupsOffset = 0x5Cu;
// Whether the game's culling is in force. Off only while VR is enabled,
// vr.object_culling is false and the race view is not Flat screen; the
// natives read it on every call.
void MkwVRSetObjectCulling(bool enabled) noexcept;
bool MkwVRObjectCullingEnabled() noexcept;
// Applies vr.object_culling for the given VR state, and remembers that state
// so the settings overlay can re-apply a changed value or race view.
void MkwVRObjectCullingApplyConfiguredSettings(bool vr_enabled) noexcept;
void MkwVRObjectCullingApplyConfiguredSettings() noexcept;
} // namespace mkw::vr
+16
View File
@@ -12,6 +12,7 @@
#include "runtime_config.h" #include "runtime_config.h"
#include "runtime_log.h" #include "runtime_log.h"
#include "vr/camera_toggle.h" #include "vr/camera_toggle.h"
#include "vr/mkw_vr_culling.h"
#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_first_person.h"
#include "vr/mkw_vr_policy.h" #include "vr/mkw_vr_policy.h"
#include "vr/openxr_diagnostics.h" #include "vr/openxr_diagnostics.h"
@@ -167,6 +168,7 @@ int g_vrFirstPersonSeat = RuntimeConfigFile::VrFirstPersonSeat() == "custom" ? 1
float g_vrCockpitUnitsPerMeter = RuntimeConfigFile::VrCockpitUnitsPerMeter(); float g_vrCockpitUnitsPerMeter = RuntimeConfigFile::VrCockpitUnitsPerMeter();
bool g_vrSteeringWheel = RuntimeConfigFile::VrSteeringWheel(); bool g_vrSteeringWheel = RuntimeConfigFile::VrSteeringWheel();
bool g_vrNativeSteeringWheel = RuntimeConfigFile::VrNativeSteeringWheel(); bool g_vrNativeSteeringWheel = RuntimeConfigFile::VrNativeSteeringWheel();
bool g_vrObjectCulling = RuntimeConfigFile::VrObjectCulling();
bool g_vrHandSteering = RuntimeConfigFile::VrHandSteering(); bool g_vrHandSteering = RuntimeConfigFile::VrHandSteering();
mkw::vr::WheelTuning g_vrWheelTuning = RuntimeConfigFile::VrWheelTuning(); mkw::vr::WheelTuning g_vrWheelTuning = RuntimeConfigFile::VrWheelTuning();
float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters(); float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters();
@@ -1410,6 +1412,7 @@ void DrawVrSettings() {
mkw::vr::MkwVRPolicySetImmersiveRaces(!g_vrFlatScreen); mkw::vr::MkwVRPolicySetImmersiveRaces(!g_vrFlatScreen);
mkw::vr::OpenXRSetImmersiveWindow(view == RuntimeConfigFile::VrRaceView::ImmersiveWindow); mkw::vr::OpenXRSetImmersiveWindow(view == RuntimeConfigFile::VrRaceView::ImmersiveWindow);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings(); mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
mkw::vr::MkwVRObjectCullingApplyConfiguredSettings();
} }
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) {
ImGui::SetTooltip( ImGui::SetTooltip(
@@ -1724,6 +1727,19 @@ void DrawVrCameraSettings() {
} }
DrawVrSteeringWheelSettings(); DrawVrSteeringWheelSettings();
ImGui::Separator(); ImGui::Separator();
ImGui::Text("Object culling");
if (ImGui::Checkbox("Hide what the game camera cannot see", &g_vrObjectCulling)) {
RuntimeConfigFile::SetVrObjectCulling(g_vrObjectCulling);
mkw::vr::MkwVRObjectCullingApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"The game's own culling: karts, characters and course objects outside its chase "
"camera's view are not drawn, so a head turn or a look over the shoulder finds them "
"missing. Off draws them anyway, at some GPU cost. The draw distance is unchanged, "
"and the Flat screen race view always keeps the game's culling.");
}
ImGui::Separator();
if (ImGui::Button("Reset first-person defaults")) { if (ImGui::Button("Reset first-person defaults")) {
g_vrFirstPersonSeat = 0; g_vrFirstPersonSeat = 0;
g_vrCockpitUnitsPerMeter = RuntimeConfigFile::kVrCockpitUnitsPerMeterDefault; g_vrCockpitUnitsPerMeter = RuntimeConfigFile::kVrCockpitUnitsPerMeterDefault;
+288
View File
@@ -0,0 +1,288 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Native replacements for the two Mario Kart functions that hide objects the
// game camera cannot see, with the VR switch that turns that hiding off. See
// vr/mkw_vr_culling.h for the design. Both are faithful reimplementations of
// the PAL RMCP01 code: with culling on (the default) they compute exactly what
// the translated originals did.
#include "vr/mkw_vr_culling.h"
#include "hle_stubs.h"
#include "isa/ppc_isa_context.h"
#include "memory.h"
#include "memory_access.h"
#include "runtime_config.h"
#include "runtime_log.h"
#include <atomic>
#include <cmath>
#include <cstdint>
#if defined(__clang__)
// The reimplementations below must round like the PowerPC originals (discrete
// single-precision operations, explicit fused ones only where the game fuses)
// and keep NaN compares unordered. cmake/PublicProducts.cmake builds this file
// on its own with the translated code's -fno-fast-math -ffp-contract=off; the
// pragmas say the same in the source.
#pragma float_control(push)
#pragma float_control(precise, on)
#endif
namespace mkw::vr {
namespace {
std::atomic<bool> g_object_culling{true};
std::atomic<bool> g_vr_enabled{false};
std::atomic<bool> g_logged{false};
} // namespace
void MkwVRSetObjectCulling(bool enabled) noexcept {
const bool previous = g_object_culling.exchange(enabled, std::memory_order_relaxed);
if (previous != enabled || !g_logged.exchange(true, std::memory_order_relaxed)) {
RT_LOG(RT_TAG_RUNTIME) << "[vr] object culling "
<< (enabled ? "on (the game's own)" : "off (objects outside the game camera are drawn)")
<< std::endl;
}
}
bool MkwVRObjectCullingEnabled() noexcept {
return g_object_culling.load(std::memory_order_relaxed);
}
void MkwVRObjectCullingApplyConfiguredSettings(bool vr_enabled) noexcept {
g_vr_enabled.store(vr_enabled, std::memory_order_relaxed);
// The setting lives under [vr]: a desktop session keeps the game's culling,
// and so does the Flat screen race view, which shows the game camera's own
// view, where everything culled is off screen anyway.
MkwVRSetObjectCulling(!vr_enabled || RuntimeConfigFile::VrObjectCulling() ||
RuntimeConfigFile::VrFlatScreen());
}
void MkwVRObjectCullingApplyConfiguredSettings() noexcept {
MkwVRObjectCullingApplyConfiguredSettings(g_vr_enabled.load(std::memory_order_relaxed));
}
} // namespace mkw::vr
namespace {
using mkw::vr::CullingAabb;
using mkw::vr::CullingFrustum;
using mkw::vr::FrustumAabbResult;
void ReadVec3(uint32_t address, float out[3]) noexcept {
for (uint32_t i = 0; i < 3; ++i) {
out[i] = MemoryInline::FlatReadFloat32(address + i * 4u);
}
}
// The translated code loads these singles straight from guest memory, so the
// unchecked flat reads it uses are the right tool here too: this runs once per
// scene object per frame.
void ReadFrustum(uint32_t frustum, CullingFrustum& out) noexcept {
ReadVec3(frustum + mkw::vr::kFrustumBoxOffset, out.box.min);
ReadVec3(frustum + mkw::vr::kFrustumBoxOffset + 12u, out.box.max);
for (uint32_t p = 0; p < mkw::vr::kFrustumPlaneCount; ++p) {
const uint32_t plane = frustum + mkw::vr::kFrustumPlanesOffset + p * mkw::vr::kFrustumPlaneStride;
ReadVec3(plane, out.planes[p].normal);
out.planes[p].distance = MemoryInline::FlatReadFloat32(plane + 12u);
}
}
// nw4r::math::FRUSTUM::IntersectAABB_Ex (0x80086610). Its one direct caller is
// nw4r::g3d::ScnObjGather::Add, with the object's own bounding box.
int32_t Nw4rFrustumIntersectAabbEx(uint32_t frustum, uint32_t aabb) {
if (!mkw::vr::MkwVRObjectCullingEnabled()) {
// Partially inside: gathered and drawn, like a box straddling a plane.
return static_cast<int32_t>(FrustumAabbResult::Partial);
}
CullingFrustum f;
ReadFrustum(frustum, f);
CullingAabb box;
ReadVec3(aabb, box.min);
ReadVec3(aabb + 12u, box.max);
return static_cast<int32_t>(mkw::vr::FrustumIntersectAabb(f, box));
}
// Guest functions UpdateScreenInfo calls, unchanged translated code.
constexpr uint32_t kPSMTXInverse = 0x80199FC8u;
constexpr uint32_t kNw4rSinCosFIdx = 0x800851E0u;
constexpr uint32_t kNw4rVec3TransformNormal = 0x80085AB0u;
constexpr uint32_t kClipInfoMgrNormalizeVector = 0x807872C0u;
constexpr uint32_t kClipInfoMgrWidenPlane = 0x807DEBCCu;
constexpr uint32_t kClipInfoMgrGetArea8And9GroupIDs = 0x80786FC0u;
// The four float constants UpdateScreenInfo reads (its r30 table) and the
// reference vector it hands the plane-widening helper (its r31).
constexpr uint32_t kClipConstants = 0x808A4808u;
constexpr uint32_t kClipReferenceVector = 0x802A4130u;
// The original's stack frame, laid out as it uses it.
constexpr uint32_t kFrameBytes = 0xA0u;
constexpr uint32_t kFrameCos = 0x08u;
constexpr uint32_t kFrameSin = 0x0Cu;
constexpr uint32_t kFrameVecA = 0x10u;
constexpr uint32_t kFrameVecB = 0x1Cu;
constexpr uint32_t kFrameVecC = 0x28u;
constexpr uint32_t kFrameForward = 0x34u;
constexpr uint32_t kFrameInverse = 0x40u;
float SingleMul(float a, float b) noexcept {
#if defined(__clang__)
#pragma clang fp contract(off)
#endif
return a * b;
}
void CopyVec3(uint32_t from, uint32_t to) noexcept {
for (uint32_t i = 0; i < 3; ++i) {
MemoryInline::FlatWriteRam32(to + i * 4u, MemoryInline::FlatRead32(from + i * 4u));
}
}
// MTX::PSVECCrossProduct (0x8019ACCC), which the original inlines: paired
// single multiply-subtracts, so each component is one fused operation.
void CrossProduct(uint32_t a_addr, uint32_t b_addr, uint32_t out_addr) noexcept {
#if defined(__clang__)
#pragma clang fp contract(off)
#endif
float a[3];
float b[3];
ReadVec3(a_addr, a);
ReadVec3(b_addr, b);
const float x_sub = SingleMul(b[1], a[2]);
const float x = std::fmaf(a[1], b[2], -x_sub);
const float y_sub = SingleMul(b[0], a[2]);
const float y = -std::fmaf(a[0], b[2], -y_sub);
const float z_sub = SingleMul(b[1], a[0]);
const float z = -std::fmaf(a[1], b[0], -z_sub);
MemoryInline::FlatWriteFloat32(out_addr, x);
MemoryInline::FlatWriteFloat32(out_addr + 4u, y);
MemoryInline::FlatWriteFloat32(out_addr + 8u, z);
}
// ClipInfoMgr::UpdateScreenInfo (0x8078707C): fills one screen's
// ClipScreenInfo from its camera. The matrix, trigonometry, normalisation and
// plane-widening steps run the game's own translated code; the few
// single-precision operations in between mirror the original instruction by
// instruction. With culling off the six plane normals are zeroed afterwards,
// so ClipInfoMgr::Update finds nothing beyond a plane.
void ClipInfoMgrUpdateScreenInfo(uint32_t screen, uint32_t camera) {
#if defined(__clang__)
#pragma clang fp contract(off)
#endif
CpuContext* ctx = CurrentCpuContext();
// The callees save and restore LR themselves; keeping the entry value
// makes this native leave it exactly as the original's epilogue did.
const uint32_t caller_lr = ctx->lr;
const uint32_t caller_sp = ctx->gpr[1];
const uint32_t sp = caller_sp - kFrameBytes;
MemoryInline::FlatWriteRam32(sp, caller_sp);
ctx->gpr[1] = sp;
const auto call = [ctx](uint32_t target, uint32_t r3, uint32_t r4, uint32_t r5 = 0, uint32_t r6 = 0) {
ctx->gpr[3] = r3;
ctx->gpr[4] = r4;
ctx->gpr[5] = r5;
ctx->gpr[6] = r6;
InvokeIndirectCpu(target, ctx);
};
const auto read = [](uint32_t address) { return MemoryInline::FlatReadFloat32(address); };
const auto write = [](uint32_t address, float value) { MemoryInline::FlatWriteFloat32(address, value); };
// The camera's view matrix, inverted into the frame.
const uint32_t view_matrix = MemoryInline::FlatRead32(camera + 0x6Cu) + 4u;
const uint32_t inverse = sp + kFrameInverse;
call(kPSMTXInverse, view_matrix, inverse);
// Camera position: the inverse's translation column.
write(screen + 0x00u, read(inverse + 0x0Cu));
write(screen + 0x04u, read(inverse + 0x1Cu));
write(screen + 0x08u, read(inverse + 0x2Cu));
write(screen + 0x0Cu, read(camera + 0x18u));
const float aspect = static_cast<float>(static_cast<double>(read(camera + 0x08u)) /
static_cast<double>(read(camera + 0x0Cu)));
const float fov = read(camera + 0x10u);
const float k0 = read(kClipConstants + 0x0u);
const float k1 = read(kClipConstants + 0x4u);
const float k2 = read(kClipConstants + 0x8u);
const float k3 = read(kClipConstants + 0xCu);
// nw4r::math::SinCosFIdx(&sin, &cos, k1 * fov)
ctx->fpr[1].d = static_cast<double>(SingleMul(k1, fov));
call(kNw4rSinCosFIdx, sp + kFrameSin, sp + kFrameCos);
// Draw-distance scale: (k3 * min(fov, k2))^2. The original keeps fov only
// on an ordered fov <= k2 (fcmpo, ble), so a NaN fov takes k2.
const float clamped_fov = mkw::vr::CullingOrderedLessOrEqual(fov, k2) ? fov : k2;
const float scaled = SingleMul(k3, clamped_fov);
write(screen + mkw::vr::kClipScreenDrawScaleOffset, SingleMul(scaled, scaled));
const float sin = read(sp + kFrameSin);
const float cos = read(sp + kFrameCos);
const float sin_aspect = SingleMul(sin, aspect);
// Left plane: (-cos, k0, sin * aspect) turned into world space, normalised.
const uint32_t plane_left = screen + 0x10u;
write(plane_left + 0u, -cos);
write(plane_left + 4u, k0);
write(plane_left + 8u, sin_aspect);
call(kNw4rVec3TransformNormal, plane_left, inverse, plane_left);
CopyVec3(plane_left, sp + kFrameVecC);
call(kClipInfoMgrNormalizeVector, plane_left, sp + kFrameVecC);
// Right plane: (cos, k0, sin * aspect).
const uint32_t plane_right = screen + 0x28u;
write(plane_right + 0u, cos);
write(plane_right + 4u, k0);
write(plane_right + 8u, SingleMul(sin, aspect));
call(kNw4rVec3TransformNormal, plane_right, inverse, plane_right);
CopyVec3(plane_right, sp + kFrameVecB);
call(kClipInfoMgrNormalizeVector, plane_right, sp + kFrameVecB);
// The view direction from the two side normals, then each side plane
// widened around it into the second pair (+0x1C and +0x34).
const uint32_t forward = sp + kFrameForward;
CrossProduct(plane_left, plane_right, forward);
CopyVec3(forward, sp + kFrameVecA);
call(kClipInfoMgrNormalizeVector, forward, sp + kFrameVecA);
call(kClipInfoMgrWidenPlane, forward, kClipReferenceVector, plane_left, screen + 0x1Cu);
call(kClipInfoMgrWidenPlane, forward, kClipReferenceVector, plane_right, screen + 0x34u);
// Top plane (k0, cos, sin) and bottom plane (k0, -cos, sin), turned into
// world space.
const uint32_t plane_top = screen + 0x40u;
write(plane_top + 0u, k0);
write(plane_top + 4u, cos);
write(plane_top + 8u, sin);
call(kNw4rVec3TransformNormal, plane_top, inverse, plane_top);
const uint32_t plane_bottom = screen + 0x4Cu;
write(plane_bottom + 0u, k0);
write(plane_bottom + 4u, -cos);
write(plane_bottom + 8u, sin);
call(kNw4rVec3TransformNormal, plane_bottom, inverse, plane_bottom);
// Area type 8 and 9 group bits for this screen's camera.
call(kClipInfoMgrGetArea8And9GroupIDs, screen, 9u);
MemoryInline::FlatWriteRam16(screen + mkw::vr::kClipScreenAreaGroupsOffset,
static_cast<uint16_t>(ctx->gpr[3] & 0xFFFFu));
ctx->gpr[1] = caller_sp;
ctx->lr = caller_lr;
if (!mkw::vr::MkwVRObjectCullingEnabled()) {
for (uint32_t offset = 0; offset < mkw::vr::kClipScreenPlanesBytes; offset += 4u) {
MemoryInline::FlatWriteRam32(screen + mkw::vr::kClipScreenPlanesOffset + offset, 0u);
}
}
}
} // namespace
PPC_NATIVE_OVERRIDE(80086610, Nw4rFrustumIntersectAabbEx, int32_t, (uint32_t frustum, uint32_t aabb), (frustum, aabb));
PPC_NATIVE_OVERRIDE_VOID(8078707C, ClipInfoMgrUpdateScreenInfo, (uint32_t screen, uint32_t camera), (screen, camera));
#if defined(__clang__)
#pragma float_control(pop)
#endif
+6
View File
@@ -187,6 +187,12 @@ constexpr MkwVRHookPoint kHookPoints[] = {
"Future culling-frustum expansion point for head movement beyond the base camera."}, "Future culling-frustum expansion point for head movement beyond the base camera."},
{0x80228180u, "EGG::Frustum::CopyToG3D", MkwVRHookCapability::Culling, {0x80228180u, "EGG::Frustum::CopyToG3D", MkwVRHookCapability::Culling,
"Observe the frustum handed to NW4R without guessing EGG::Frustum fields."}, "Observe the frustum handed to NW4R without guessing EGG::Frustum fields."},
{0x80086610u, "nw4r::math::FRUSTUM::IntersectAABB_Ex", MkwVRHookCapability::Culling,
"Replaced natively (mkw_vr_culling.cpp): every box counts as partially inside while "
"vr.object_culling is off."},
{0x8078707Cu, "ClipInfoMgr::UpdateScreenInfo", MkwVRHookCapability::Culling,
"Replaced natively (mkw_vr_culling.cpp): the screen's side-plane normals are zeroed "
"while vr.object_culling is off."},
}; };
} // namespace } // namespace
+2
View File
@@ -9,6 +9,7 @@
#include "runtime_config.h" #include "runtime_config.h"
#include "gx_thread.h" #include "gx_thread.h"
#include "runtime_log.h" #include "runtime_log.h"
#include "vr/mkw_vr_culling.h"
#include "vr/mkw_vr_first_person.h" #include "vr/mkw_vr_first_person.h"
#include "vr/mkw_vr_policy.h" #include "vr/mkw_vr_policy.h"
#include "vr/mkw_vr_instrumentation.h" #include "vr/mkw_vr_instrumentation.h"
@@ -75,6 +76,7 @@ void ConfigurePolicy(bool enabled) noexcept {
MkwVRPolicyConfigure(config); MkwVRPolicyConfigure(config);
MkwVRInstrumentationInitialize(); MkwVRInstrumentationInitialize();
MkwVRFirstPersonApplyConfiguredSettings(); MkwVRFirstPersonApplyConfiguredSettings();
MkwVRObjectCullingApplyConfiguredSettings(enabled);
} }
#if MKW_OPENXR_GRAPHICS_BACKEND #if MKW_OPENXR_GRAPHICS_BACKEND
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// The frustum test behind Mario Kart's scene culling (vr/mkw_vr_culling.h):
// the native replacement of nw4r::math::FRUSTUM::IntersectAABB_Ex has to
// classify boxes exactly as the PowerPC original, since with vr.object_culling
// on it runs for every scene object of every frame.
#include "vr/mkw_vr_culling.h"
#include <cmath>
#include <cstdint>
#include <cstring>
#include <iostream>
using mkw::vr::CullingAabb;
using mkw::vr::CullingFrustum;
using mkw::vr::CullingPlane;
using mkw::vr::FrustumAabbResult;
using mkw::vr::FrustumIntersectAabb;
namespace {
// nw4r's return codes, which ScnObjGather::Add tests by value.
static_assert(static_cast<int32_t>(FrustumAabbResult::Outside) == 0, "");
static_assert(static_cast<int32_t>(FrustumAabbResult::Inside) == 1, "");
static_assert(static_cast<int32_t>(FrustumAabbResult::Partial) == 2, "");
// The layout the native reads from guest memory.
static_assert(mkw::vr::kFrustumPlanesOffset - mkw::vr::kFrustumBoxOffset == sizeof(CullingAabb), "");
static_assert(sizeof(CullingPlane) == mkw::vr::kFrustumPlaneStride, "");
static_assert(mkw::vr::kClipScreenPlanesOffset + mkw::vr::kClipScreenPlanesBytes ==
mkw::vr::kClipScreenDrawScaleOffset,
"six plane normals fill the screen info up to the draw scale");
// NaN and infinity from their bits: a literal one is undefined behaviour when
// the test is built with the runtime's -ffast-math, which it is worth trying.
float FloatFromBits(uint32_t bits) {
float value;
std::memcpy(&value, &bits, sizeof(value));
return value;
}
CullingAabb Box(float x0, float y0, float z0, float x1, float y1, float z1) {
return CullingAabb{{x0, y0, z0}, {x1, y1, z1}};
}
// An axis-aligned "frustum": six outward planes at +-half on each axis, and
// the frustum's own bounding box at +-box_half.
CullingFrustum AxisFrustum(float half, float box_half) {
CullingFrustum f{};
f.box = Box(-box_half, -box_half, -box_half, box_half, box_half, box_half);
size_t p = 0;
for (size_t axis = 0; axis < 3; ++axis) {
for (float sign : {1.0f, -1.0f}) {
CullingPlane& plane = f.planes[p++];
plane.normal[axis] = sign;
plane.distance = -half;
}
}
return f;
}
} // namespace
int main() {
int failures = 0;
const auto check = [&](bool condition, const char* message) {
if (!condition) { std::cerr << "FAILED: " << message << '\n'; ++failures; }
};
const auto expect = [&](FrustumAabbResult got, FrustumAabbResult want, const char* message) {
if (got != want) {
std::cerr << "FAILED: " << message << " (got " << static_cast<int>(got) << ", want "
<< static_cast<int>(want) << ")\n";
++failures;
}
};
const CullingFrustum f = AxisFrustum(10.0f, 10.0f);
expect(FrustumIntersectAabb(f, Box(-1, -1, -1, 1, 1, 1)), FrustumAabbResult::Inside,
"a box within every plane is inside");
expect(FrustumIntersectAabb(f, Box(5, -1, -1, 15, 1, 1)), FrustumAabbResult::Partial,
"a box across the +x plane is partial");
expect(FrustumIntersectAabb(f, Box(-15, -1, -1, -5, 1, 1)), FrustumAabbResult::Partial,
"a box across the -x plane is partial (the negative normal picks the other corner)");
expect(FrustumIntersectAabb(f, Box(11, -1, -1, 12, 1, 1)), FrustumAabbResult::Outside,
"a box past the frustum's bounding box is rejected");
expect(FrustumIntersectAabb(f, Box(-1, -1, -12, 1, 1, -11)), FrustumAabbResult::Outside,
"the bounding-box rejection covers z as well");
expect(FrustumIntersectAabb(f, Box(-10, -10, -10, 10, 10, 10)), FrustumAabbResult::Inside,
"touching every plane exactly is still inside (outside needs strictly greater)");
// Planes tighter than the frustum's bounding box: only the plane test can
// reject.
const CullingFrustum tight = AxisFrustum(10.0f, 100.0f);
expect(FrustumIntersectAabb(tight, Box(20, -1, -1, 30, 1, 1)), FrustumAabbResult::Outside,
"a box beyond the +x plane is outside");
expect(FrustumIntersectAabb(tight, Box(-1, -30, -1, 1, -20, 1)), FrustumAabbResult::Outside,
"a box beyond the -y plane is outside");
expect(FrustumIntersectAabb(tight, Box(-50, -50, -50, 50, 50, 50)), FrustumAabbResult::Partial,
"a box enclosing the whole frustum is partial");
// NaN follows each branch of the original. The plane tests keep going only
// on an ordered "<= 0", so a NaN distance rejects the box.
const float nan = FloatFromBits(0x7FC00000u);
expect(FrustumIntersectAabb(f, Box(nan, -1, -1, nan, 1, 1)), FrustumAabbResult::Outside,
"a NaN coordinate makes a plane distance NaN, which rejects");
CullingFrustum nan_plane = f;
nan_plane.planes[0].normal[0] = nan;
expect(FrustumIntersectAabb(nan_plane, Box(-1, -1, -1, 1, 1, 1)), FrustumAabbResult::Outside,
"a NaN normal gives a NaN distance, which rejects");
// The bounding-box compares reject on an ordered ">" (bgt), except the last
// one, which keeps on an ordered "<=" (ble). Planes that accept everything
// isolate those compares.
CullingFrustum open{};
open.box = Box(-10, -10, -10, 10, 10, 10);
for (CullingPlane& plane : open.planes) plane.distance = -1.0f;
expect(FrustumIntersectAabb(open, Box(-1, -1, -1, 1, 1, 1)), FrustumAabbResult::Inside,
"zero-normal planes accept a box inside the bounding box");
open.box.min[0] = nan;
expect(FrustumIntersectAabb(open, Box(-1, -1, -1, 1, 1, 1)), FrustumAabbResult::Inside,
"a NaN frustum min.x fails bgt and keeps the box");
open.box.min[0] = -10.0f;
open.box.min[2] = nan;
expect(FrustumIntersectAabb(open, Box(-1, -1, -1, 1, 1, 1)), FrustumAabbResult::Outside,
"a NaN frustum min.z fails ble and rejects the box");
open.box.min[2] = -10.0f;
open.box.max[2] = nan;
expect(FrustumIntersectAabb(open, Box(-1, -1, -1, 1, 1, 1)), FrustumAabbResult::Inside,
"a NaN frustum max.z fails bgt and keeps the box");
check(mkw::vr::CullingIsNan(nan) && !mkw::vr::CullingIsNan(1.0f) &&
!mkw::vr::CullingIsNan(FloatFromBits(0x7F800000u)),
"the bit-level NaN test");
// The plane distance is one fused multiply-add for x (ps_madd) after a
// rounded product for y: choose values where fusing decides the sign.
// nx * vx = 1 + 2^-22 + 2^-46 exactly, which rounds to 1 + 2^-22 on its
// own; yy cancels that rounded value, so only a fused step keeps 2^-46.
{
const float one_plus = 1.0f + std::ldexp(1.0f, -23);
CullingFrustum fused = AxisFrustum(1000.0f, 1000.0f);
fused.planes[0].normal[0] = one_plus;
fused.planes[0].normal[1] = 1.0f;
fused.planes[0].normal[2] = 0.0f;
fused.planes[0].distance = -std::ldexp(1.0f, -47);
const float yy = -(1.0f + std::ldexp(1.0f, -22));
expect(FrustumIntersectAabb(fused, Box(one_plus, yy, 0, one_plus, yy, 0)),
FrustumAabbResult::Outside, "the x term is fused like ps_madd, so 2^-46 survives");
fused.planes[0].distance = -std::ldexp(1.0f, -45);
expect(FrustumIntersectAabb(fused, Box(one_plus, yy, 0, one_plus, yy, 0)),
FrustumAabbResult::Inside, "and a larger distance still keeps the point inside");
}
check(mkw::vr::kFrustumPlaneCount == 6, "six frustum planes");
if (failures == 0) {
std::cout << "vr_culling_tests: all passed\n";
}
return failures == 0 ? 0 : 1;
}