fix(vr): stop keyboard click-through, redraw churn and upload leaks; build its drawing only with VR

This commit is contained in:
vii committed 2026-10-13 18:37:34 -05:00
1 parent f9289d9947
commit 7ec8304d8f
12 files changed
+318 -85

No files matched your search

+11 -8
View File
@@ -32,17 +32,11 @@ add_library(mocktail_vr STATIC
src/vr/vr_mirror.cc
src/vr/xr_controller.cc
src/vr/roblox_vr_device_bridge.cc
src/vr/virtual_keyboard.cc
src/vr/virtual_keyboard_raster.cc)
src/vr/virtual_keyboard.cc)
add_library(Mocktail::VR ALIAS mocktail_vr)
target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include")
target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/src")
target_link_libraries(mocktail_vr PUBLIC Vulkan::Headers PRIVATE nlohmann_json::nlohmann_json)
# The in-headset keyboard draws its labels with the same SDL3_ttf/fontconfig
# stack as the TextBox overlay. MocktailInput/MocktailAudio find these
# packages later in the same directory scope; links resolve at generate time.
target_link_libraries(mocktail_vr PRIVATE SDL3_ttf::SDL3_ttf SDL3::SDL3
PkgConfig::FONTCONFIG)
# shm_open for the desktop eye mirror lives in librt on glibc.
find_library(MOCKTAIL_RT_LIBRARY rt)
if(MOCKTAIL_RT_LIBRARY)
@@ -75,7 +69,16 @@ if(MOCKTAIL_ENABLE_VR)
endfunction()
mocktail_add_openxr_sdk()
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc
src/vr/openxr_backend.cc src/vr/gles_transport.cc src/vr/xr_actions.cc)
src/vr/openxr_backend.cc src/vr/gles_transport.cc src/vr/xr_actions.cc
src/vr/virtual_keyboard_raster.cc)
# The in-headset keyboard draws its labels with the same SDL3_ttf and
# fontconfig stack as the TextBox overlay.
find_package(SDL3 3.4 REQUIRED CONFIG)
find_package(SDL3_ttf REQUIRED CONFIG)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
target_link_libraries(mocktail_vr PRIVATE SDL3_ttf::SDL3_ttf SDL3::SDL3
PkgConfig::FONTCONFIG)
# Error unwinding is confined to these native session implementations; their
# public entry points catch errors before returning to the guest runtime.
set_source_files_properties(src/vr/openxr_preview.cc src/vr/openxr_backend.cc
+1
View File
@@ -336,6 +336,7 @@ class OpenXrBackend final {
// keyboard swapchain holds a released image ready for a quad layer.
bool PrepareVirtualKeyboardLayerLocked(VkQueue queue);
bool UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image);
void DestroyVirtualKeyboardStaging();
void DestroyVirtualKeyboardResources();
bool CaptureEyeEvidence(VkQueue queue, int eye);
ScriptedPoseSample ComputeScriptedPose(std::uint64_t frame) const;
+6 -3
View File
@@ -74,7 +74,6 @@ struct VirtualKeyboardPointer {
struct VirtualKeyboardOutput {
std::vector<std::string> text; // committed in order
std::vector<std::uint32_t> scancodes; // pressed in order, after text
bool closed = false; // user pressed Close
};
// Ray/quad intersection. The quad is centred on `quad` with its face along
@@ -118,8 +117,9 @@ class VirtualKeyboard {
const VirtualKeyboardPointer (&pointers)[2]);
// True when this hand's trigger belongs to the keyboard this frame (aimed
// at the panel, or a press that began on it is still held). The backend
// withholds that trigger from Roblox so a key press is not also a click.
// at the panel, or a press that began on it is still held, including after
// the panel hid mid-press). The backend withholds that trigger from Roblox
// so a key press is not also a click.
bool CapturesTrigger(int hand) const;
// Current layout, with Shift/Caps Lock applied to the labels.
@@ -159,6 +159,9 @@ class VirtualKeyboard {
pose::Pose panel_{};
VirtualKeyboardText text_;
Hand hands_[2];
// A press the keyboard owned when it hid; held until that trigger releases.
bool release_pending_[2] = {false, false};
std::uint64_t cursor_drawn_ns_ = 0;
std::vector<VirtualKey> keys_;
std::uint64_t revision_ = 1;
};
+3 -5
View File
@@ -46,6 +46,7 @@
#include "runtime/roblox_desktop_app_policy.h"
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_fullscreen_runtime_bridge.h"
#include "runtime/roblox_text_display_state.h"
#include "runtime/roblox_text_editor.h"
#include "runtime/runtime_config_bootstrap.h"
#include "runtime/runtime_config_file.h"
@@ -1082,13 +1083,10 @@ int main(int argc, char* argv[]) {
mocktail::vr::ClearVirtualKeyboardText();
return;
}
// RbxKeyboard password input types (see the SDL mapping).
const bool password = update.text_input_type == 5 ||
update.text_input_type == 9 ||
update.text_input_type == 10;
mocktail::vr::SetVirtualKeyboardText(
update.utf8, update.utf8_size, update.cursor_utf16,
password);
mocktail::runtime::IsRobloxPasswordTextInputType(
update.text_input_type));
}});
}
}
@@ -328,11 +328,15 @@ Status RobloxTextSurfaceOverlay::UpdateViewport(
namespace {
std::mutex g_display_mirror_mutex;
RobloxTextDisplaySink g_display_mirror;
// Lets a text update skip the lock while no mirror is installed.
std::atomic<bool> g_display_mirror_set{false};
} // namespace
void SetRobloxTextDisplayMirror(RobloxTextDisplaySink sink) {
std::lock_guard<std::mutex> lock(g_display_mirror_mutex);
g_display_mirror = sink;
g_display_mirror_set.store(sink.update != nullptr,
std::memory_order_release);
}
RobloxTextDisplaySink RobloxTextSurfaceOverlay::sink() {
@@ -344,6 +348,9 @@ void RobloxTextSurfaceOverlay::UpdateCallback(
if (context != nullptr) {
static_cast<RobloxTextSurfaceOverlay*>(context)->ApplyUpdate(update);
}
if (!g_display_mirror_set.load(std::memory_order_acquire)) {
return;
}
std::lock_guard<std::mutex> lock(g_display_mirror_mutex);
if (g_display_mirror.update != nullptr) {
g_display_mirror.update(g_display_mirror.context, update);
+22 -8
View File
@@ -2725,8 +2725,10 @@ void OpenXrBackend::UpdateVirtualKeyboardLocked() {
return;
}
const VirtualKeyboardHost host = CurrentVirtualKeyboardHost();
// A panel that cannot be drawn must not take triggers either.
const bool requested =
host.input_requested != nullptr && host.input_requested(host.context);
!keyboard_swapchain_failed_ && host.input_requested != nullptr &&
host.input_requested(host.context);
if (!requested) {
keyboard_.Hide();
keyboard_armed_ = true;
@@ -2735,10 +2737,9 @@ void OpenXrBackend::UpdateVirtualKeyboardLocked() {
keyboard_armed_ = false;
Log(" [vr-keyboard] shown for focused TextBox\n");
}
if (!keyboard_.visible()) {
return;
}
{
// A hidden keyboard still runs below: it keeps a trigger that was pressed
// on the panel away from Roblox until that trigger is released.
if (keyboard_.visible()) {
VirtualKeyboardText text = CurrentVirtualKeyboardText();
keyboard_.SetText(text);
std::fill(text.utf8.begin(), text.utf8.end(), '\0');
@@ -2904,6 +2905,8 @@ bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) {
if (vk_->CreateBuffer(vk_device_, &info, nullptr, &keyboard_staging_) !=
VK_SUCCESS) {
keyboard_staging_ = VK_NULL_HANDLE;
keyboard_swapchain_failed_ = true;
Log(" [vr-keyboard] disabled: no staging buffer\n");
return false;
}
VkMemoryRequirements requirements{};
@@ -2932,7 +2935,11 @@ bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) {
keyboard_staging_memory_, 0) != VK_SUCCESS ||
vk_->MapMemory(vk_device_, keyboard_staging_memory_, 0, size, 0,
&keyboard_staging_mapped_) != VK_SUCCESS) {
DestroyVirtualKeyboardResources();
// The caller still holds an acquired swapchain image, so only the
// staging objects go; the latch stops a retry on every frame.
DestroyVirtualKeyboardStaging();
keyboard_swapchain_failed_ = true;
Log(" [vr-keyboard] disabled: no staging memory\n");
return false;
}
}
@@ -2987,12 +2994,15 @@ bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) {
if (vk_->WaitForFences(vk_device_, 1, &copy_fence_, VK_TRUE,
2'000'000'000ULL) != VK_SUCCESS) {
(void)IdleHostDevice(vk_->DeviceWaitIdle, vk_device_);
throw std::runtime_error("keyboard upload fence wait failed");
// Returning lets the caller release the acquired swapchain image.
keyboard_swapchain_failed_ = true;
Log(" [vr-keyboard] disabled: upload fence wait failed\n");
return false;
}
return true;
}
void OpenXrBackend::DestroyVirtualKeyboardResources() {
void OpenXrBackend::DestroyVirtualKeyboardStaging() {
if (vk_ != nullptr && vk_device_ != VK_NULL_HANDLE) {
if (keyboard_staging_mapped_ != nullptr) {
vk_->UnmapMemory(vk_device_, keyboard_staging_memory_);
@@ -3007,6 +3017,10 @@ void OpenXrBackend::DestroyVirtualKeyboardResources() {
keyboard_staging_mapped_ = nullptr;
keyboard_staging_ = VK_NULL_HANDLE;
keyboard_staging_memory_ = VK_NULL_HANDLE;
}
void OpenXrBackend::DestroyVirtualKeyboardResources() {
DestroyVirtualKeyboardStaging();
EyeSwapchain& slot = keyboard_swapchain_;
slot.images.clear();
slot.gl_images.clear();
+3
View File
@@ -4,6 +4,9 @@
namespace mocktail::vr {
OpenXrBackend::~OpenXrBackend() = default;
// The backend's keyboard_raster_ member needs this destructor. No rasterizer
// is ever created here, so SDL3_ttf and fontconfig stay out of this build.
VirtualKeyboardRasterizer::~VirtualKeyboardRasterizer() = default;
Status OpenXrBackend::Arm(VrGraphicsApi) {
return Status::Error(StatusCode::kUnavailable, "OpenXR support is disabled in this build");
}
+28 -6
View File
@@ -30,6 +30,9 @@ constexpr std::uint64_t kRepeatDelayNs = 450'000'000ULL;
constexpr std::uint64_t kRepeatIntervalNs = 60'000'000ULL;
// Cursor moves smaller than this (in UV) do not force a redraw.
constexpr float kCursorRedrawStep = 0.004f;
// A moving cursor redraws the panel at most this often; each redraw is a
// full texture upload on the frame thread.
constexpr std::uint64_t kCursorRedrawIntervalNs = 33'000'000ULL;
constexpr float kPanelDistanceMetres = 0.45f;
constexpr float kPanelDropMetres = 0.28f;
@@ -239,7 +242,13 @@ void VirtualKeyboard::Hide() {
text_.revision = UINT64_MAX; // the next SetText always reloads
if (!visible_) return;
visible_ = false;
for (Hand& hand : hands_) hand = Hand{};
for (int index = 0; index < 2; ++index) {
// Keeps a press already pending from an earlier hide of this same press.
release_pending_[index] =
release_pending_[index] ||
(hands_[index].trigger_down && hands_[index].captured);
hands_[index] = Hand{};
}
Touch();
}
@@ -255,7 +264,8 @@ int VirtualKeyboard::KeyAt(float u, float v) const {
bool VirtualKeyboard::CapturesTrigger(int hand) const {
const Hand& state = hands_[hand & 1];
return visible_ && (state.over_panel || state.captured);
return release_pending_[hand & 1] ||
(visible_ && (state.over_panel || state.captured));
}
bool VirtualKeyboard::cursor(int hand, float* u, float* v) const {
@@ -298,7 +308,6 @@ void VirtualKeyboard::Activate(const VirtualKey& key,
return;
case VirtualKeyAction::kClose:
output->scancodes.push_back(kScancodeEscape);
output->closed = true;
Hide();
return;
}
@@ -307,8 +316,18 @@ void VirtualKeyboard::Activate(const VirtualKey& key,
VirtualKeyboardOutput VirtualKeyboard::Update(
std::uint64_t now_ns, const VirtualKeyboardPointer (&pointers)[2]) {
VirtualKeyboardOutput output;
if (!visible_) return output;
bool held_over[2] = {false, false};
for (int index = 0; index < 2; ++index) {
if (!release_pending_[index]) continue;
// Losing the aim pose is not a release: the trigger is read regardless.
held_over[index] = pointers[index].trigger > kTriggerRelease;
release_pending_[index] = held_over[index];
}
// Decided once per frame, or one moving hand starves the other's cursor.
const bool cursor_redraw_due =
now_ns - cursor_drawn_ns_ >= kCursorRedrawIntervalNs;
for (int index = 0; index < 2 && visible_; ++index) {
if (held_over[index]) continue;
Hand& hand = hands_[index];
const VirtualKeyboardPointer& pointer = pointers[index];
float u = 0.f, v = 0.f;
@@ -318,12 +337,15 @@ VirtualKeyboardOutput VirtualKeyboard::Update(
kPanelHeightMetres, pointer.aim, &u, &v,
nullptr);
const int hovered = over ? KeyAt(u, v) : -1;
const bool cursor_moved =
over && (std::abs(u - hand.u) > kCursorRedrawStep ||
std::abs(v - hand.v) > kCursorRedrawStep);
if (over != hand.over_panel || hovered != hand.hovered ||
(over && (std::abs(u - hand.u) > kCursorRedrawStep ||
std::abs(v - hand.v) > kCursorRedrawStep))) {
(cursor_moved && cursor_redraw_due)) {
Touch();
hand.u = u;
hand.v = v;
cursor_drawn_ns_ = now_ns;
}
hand.over_panel = over;
hand.hovered = hovered;
+5 -1
View File
@@ -179,6 +179,8 @@ struct VirtualInput {
};
static std::mutex g_virtual_input_mutex;
static std::deque<VirtualInput> g_virtual_input;
// Set while the queue may hold input, so a pump with none takes no lock.
static std::atomic<bool> g_virtual_input_pending{false};
static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
// The owner is created and destroyed on the SDL thread, but the experience
// composition reaches it from its own thread. The mutex guards the pointer
@@ -1699,6 +1701,7 @@ void QueueVirtualTextInput(const char* utf8) {
}
std::lock_guard<std::mutex> lock(g_virtual_input_mutex);
g_virtual_input.push_back(VirtualInput{utf8, 0});
g_virtual_input_pending.store(true, std::memory_order_release);
}
void QueueVirtualKeyPress(uint32_t scancode) {
if (scancode == 0) {
@@ -1706,6 +1709,7 @@ void QueueVirtualKeyPress(uint32_t scancode) {
}
std::lock_guard<std::mutex> lock(g_virtual_input_mutex);
g_virtual_input.push_back(VirtualInput{{}, scancode});
g_virtual_input_pending.store(true, std::memory_order_release);
}
void* GetGLProcAddress(const char* name) {
if (!g_state.initialised || name == nullptr || name[0] == '\0') {
@@ -2320,7 +2324,7 @@ bool PumpEvents() {
g_text_input_owner->active())) {
fprintf(stderr, " [input] SDL pointer capture state update failed\n");
}
{
if (g_virtual_input_pending.exchange(false, std::memory_order_acq_rel)) {
std::deque<VirtualInput> virtual_input;
{
std::lock_guard<std::mutex> lock(g_virtual_input_mutex);
+6
View File
@@ -64,6 +64,12 @@ if(MOCKTAIL_ENABLE_VR)
target_include_directories(openxr_backend_test PRIVATE "${PROJECT_SOURCE_DIR}/third_party/OpenXR-SDK/include")
target_link_libraries(openxr_backend_test PRIVATE Mocktail::VR GTest::gtest_main)
gtest_discover_tests(openxr_backend_test)
add_executable(virtual_keyboard_raster_test
vr/virtual_keyboard_raster_test.cc)
target_link_libraries(virtual_keyboard_raster_test PRIVATE
Mocktail::VR GTest::gtest_main)
mocktail_apply_compile_options(virtual_keyboard_raster_test)
gtest_discover_tests(virtual_keyboard_raster_test)
add_executable(vr_queue_submission_test vr/queue_submission_test.cc)
target_link_libraries(vr_queue_submission_test PRIVATE
Mocktail::VR stub_vulkan GTest::gtest_main)
+96
View File
@@ -0,0 +1,96 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <string>
#include <vector>
#include "mocktail/vr/virtual_keyboard.h"
namespace mocktail::vr {
namespace {
using pose::Pose;
using pose::Vec3;
Pose Head() {
Pose head;
head.position = Vec3{0.f, 1.6f, 0.f};
return head;
}
int FindKey(const VirtualKeyboard& keyboard, const std::string& label) {
const auto& keys = keyboard.keys();
for (std::size_t i = 0; i < keys.size(); ++i)
if (keys[i].label == label) return static_cast<int>(i);
return -1;
}
void SetText(VirtualKeyboard* keyboard, const std::string& utf8, bool password,
std::uint64_t revision) {
VirtualKeyboardText text;
text.utf8 = utf8;
text.cursor_utf16 = static_cast<int>(utf8.size());
text.password = password;
text.revision = revision;
keyboard->SetText(text);
}
TEST(VirtualKeyboardRasterTest, DrawsFieldTextAndCaret) {
VirtualKeyboard keyboard;
keyboard.Show(Head());
VirtualKeyboardRasterizer rasterizer;
auto field_light_pixels = [&](const std::vector<std::uint8_t>& rgba) {
const int bottom = static_cast<int>(VirtualKeyboard::kFieldFraction *
VirtualKeyboard::kTextureHeight);
int count = 0;
for (int y = 8; y < bottom - 8; ++y)
for (int x = 30; x < VirtualKeyboard::kTextureWidth - 30; ++x)
if (rgba[(static_cast<std::size_t>(y) *
VirtualKeyboard::kTextureWidth + x) * 4 + 1] > 120)
++count;
return count;
};
std::vector<std::uint8_t> empty, typed, masked;
if (!rasterizer.Draw(keyboard, &empty)) GTEST_SKIP() << "no host font";
SetText(&keyboard, "Hello world", false, 1);
ASSERT_TRUE(rasterizer.Draw(keyboard, &typed));
EXPECT_GT(field_light_pixels(typed), field_light_pixels(empty) + 200);
SetText(&keyboard, "Hello world", true, 2);
ASSERT_TRUE(rasterizer.Draw(keyboard, &masked));
EXPECT_NE(masked, typed); // dots, not the letters
EXPECT_GT(field_light_pixels(masked), field_light_pixels(empty));
}
TEST(VirtualKeyboardRasterTest, PasswordPictureDoesNotDependOnItsLetters) {
VirtualKeyboard keyboard;
keyboard.Show(Head());
VirtualKeyboardRasterizer rasterizer;
std::vector<std::uint8_t> first, second;
SetText(&keyboard, "Hello world", true, 1);
if (!rasterizer.Draw(keyboard, &first)) GTEST_SKIP() << "no host font";
SetText(&keyboard, "Jumpy vixen", true, 2);
ASSERT_TRUE(rasterizer.Draw(keyboard, &second));
EXPECT_EQ(first, second);
}
TEST(VirtualKeyboardRasterTest, FillsAnOpaqueTexture) {
VirtualKeyboard keyboard;
keyboard.Show(Head());
VirtualKeyboardRasterizer rasterizer;
std::vector<std::uint8_t> rgba;
(void)rasterizer.Draw(keyboard, &rgba); // labels depend on host fonts
ASSERT_EQ(rgba.size(), static_cast<std::size_t>(
VirtualKeyboard::kTextureWidth) *
VirtualKeyboard::kTextureHeight * 4);
for (std::size_t i = 3; i < rgba.size(); i += 4) ASSERT_EQ(rgba[i], 255);
// A key is lighter than the background behind it.
const auto& q = keyboard.keys()[FindKey(keyboard, "q")];
const int x = static_cast<int>(q.u0 * VirtualKeyboard::kTextureWidth) + 20;
const int y = static_cast<int>(q.v0 * VirtualKeyboard::kTextureHeight) + 20;
const std::size_t key_px =
(static_cast<std::size_t>(y) * VirtualKeyboard::kTextureWidth + x) * 4;
EXPECT_GT(rgba[key_px], rgba[0]);
}
} // namespace
} // namespace mocktail::vr
+130 -54
View File
@@ -346,13 +346,141 @@ TEST(VirtualKeyboardTest, LayoutRowsFillThePanelAndCloseKeyHides) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const auto closed = d.Frame(AimAtKey(d.keyboard, "Close"), 1.f);
EXPECT_TRUE(closed.closed);
EXPECT_EQ(closed.scancodes, std::vector<std::uint32_t>{kEscape});
EXPECT_FALSE(d.keyboard.visible());
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
EXPECT_TRUE(d.Frame(AimAtKey(d.keyboard, "q"), 0.f).text.empty());
}
TEST(VirtualKeyboardTest, TriggerStaysWithheldAfterCloseUntilReleased) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const Pose close = AimAtKey(d.keyboard, "Close");
d.Frame(close, 1.f);
ASSERT_FALSE(d.keyboard.visible());
// The trigger that pressed Close is still down; Roblox must not see it as
// a click on whatever is behind the panel.
EXPECT_TRUE(d.keyboard.CapturesTrigger(1));
EXPECT_FALSE(d.keyboard.CapturesTrigger(0));
d.Frame(close, 1.f);
EXPECT_TRUE(d.keyboard.CapturesTrigger(1));
d.Frame(close, 0.f);
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
d.Frame(close, 1.f); // a new press belongs to Roblox
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
}
TEST(VirtualKeyboardTest, TriggerStaysWithheldWhenThePanelHidesMidPress) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const Pose enter = AimAtKey(d.keyboard, "Enter");
d.Frame(enter, 1.f);
d.keyboard.Hide(); // Enter made the TextBox release focus
EXPECT_TRUE(d.keyboard.CapturesTrigger(1));
d.Frame(enter, 0.f);
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
// A hand that was only hovering is free at once.
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
d.Frame(enter, 0.f);
d.keyboard.Hide();
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
}
TEST(VirtualKeyboardTest, HeldTriggerTypesNothingWhenThePanelReopens) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
d.Frame(AimAtKey(d.keyboard, "Close"), 1.f);
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const Pose q = AimAtKey(d.keyboard, "q");
EXPECT_TRUE(d.Frame(q, 1.f).text.empty());
d.Frame(q, 0.f);
EXPECT_EQ(d.Frame(q, 1.f).text, std::vector<std::string>{"q"});
}
TEST(VirtualKeyboardTest, CursorMovesRedrawAtMostThirtyTimesASecond) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const VirtualKey space = d.keyboard.keys()[FindKey(d.keyboard, "Space")];
const float v = (space.v0 + space.v1) / 2;
float u = 0.10f;
d.Frame(AimAt(d.keyboard, u, v), 0.f);
const std::uint64_t before = d.keyboard.revision();
// 30 frames at 90 Hz sliding along one key: 330 ms of pointer motion.
for (int frame = 0; frame < 30; ++frame) {
u += 0.01f;
d.Frame(AimAt(d.keyboard, u, v), 0.f);
}
const std::uint64_t redraws = d.keyboard.revision() - before;
EXPECT_LE(redraws, 11u);
EXPECT_GE(redraws, 5u);
// Once the pointer rests, the drawn cursor catches up with it.
d.Frame(AimAt(d.keyboard, u, v), 0.f, 1, 40 * kMs);
float drawn_u = 0.f, drawn_v = 0.f;
ASSERT_TRUE(d.keyboard.cursor(1, &drawn_u, &drawn_v));
EXPECT_NEAR(drawn_u, u, 1e-3f);
}
TEST(VirtualKeyboardTest, BothCursorsKeepUpWhileBothHandsMove) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const VirtualKey space = d.keyboard.keys()[FindKey(d.keyboard, "Space")];
const float v = (space.v0 + space.v1) / 2;
float left_u = 0.10f, right_u = 0.50f;
auto frame = [&] {
VirtualKeyboardPointer pointers[2];
pointers[0].valid = pointers[1].valid = true;
pointers[0].aim = AimAt(d.keyboard, left_u, v);
pointers[1].aim = AimAt(d.keyboard, right_u, v);
d.now += 11 * kMs;
d.keyboard.Update(d.now, pointers);
};
frame();
// 30 frames at 90 Hz, both hands sliding along one key.
for (int step = 0; step < 30; ++step) {
left_u += 0.005f;
right_u += 0.005f;
frame();
}
// Neither drawn cursor trails its hand by more than one redraw interval.
float drawn_u = 0.f, drawn_v = 0.f;
ASSERT_TRUE(d.keyboard.cursor(0, &drawn_u, &drawn_v));
EXPECT_NEAR(drawn_u, left_u, 0.02f);
ASSERT_TRUE(d.keyboard.cursor(1, &drawn_u, &drawn_v));
EXPECT_NEAR(drawn_u, right_u, 0.02f);
}
TEST(VirtualKeyboardTest, HeldTriggerStaysWithheldWhenAReopenedPanelHides) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
const Pose close = AimAtKey(d.keyboard, "Close");
d.Frame(close, 1.f);
// The panel reopens and hides again during the same trigger press.
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
d.Frame(close, 1.f);
d.keyboard.Hide();
EXPECT_TRUE(d.keyboard.CapturesTrigger(1));
d.Frame(close, 1.f);
EXPECT_TRUE(d.keyboard.CapturesTrigger(1));
d.Frame(close, 0.f);
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
}
TEST(VirtualKeyboardTest, HeldTriggerStaysWithheldWhileAimTrackingDrops) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
d.Frame(AimAtKey(d.keyboard, "Close"), 1.f);
ASSERT_TRUE(d.keyboard.CapturesTrigger(1));
// The aim pose is lost for a frame; the trigger is still down.
VirtualKeyboardPointer lost[2];
lost[1].trigger = 1.f;
d.now += 11 * kMs;
d.keyboard.Update(d.now, lost);
EXPECT_TRUE(d.keyboard.CapturesTrigger(1));
lost[1].trigger = 0.f;
d.now += 11 * kMs;
d.keyboard.Update(d.now, lost);
EXPECT_FALSE(d.keyboard.CapturesTrigger(1));
}
TEST(VirtualKeyboardTest, RevisionMovesOnlyWhenTheImageChanges) {
Driver d;
d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
@@ -413,57 +541,5 @@ TEST(VirtualKeyboardTest, TextMirrorFollowsTheFocusedTextBox) {
EXPECT_TRUE(CurrentVirtualKeyboardText().utf8.empty());
}
TEST(VirtualKeyboardTest, RasterizerDrawsFieldTextAndCaret) {
VirtualKeyboard keyboard;
keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
VirtualKeyboardRasterizer rasterizer;
auto field_light_pixels = [&](const std::vector<std::uint8_t>& rgba) {
const int bottom = static_cast<int>(VirtualKeyboard::kFieldFraction *
VirtualKeyboard::kTextureHeight);
int count = 0;
for (int y = 8; y < bottom - 8; ++y)
for (int x = 30; x < VirtualKeyboard::kTextureWidth - 30; ++x)
if (rgba[(static_cast<std::size_t>(y) *
VirtualKeyboard::kTextureWidth + x) * 4 + 1] > 120)
++count;
return count;
};
std::vector<std::uint8_t> empty, typed, masked;
if (!rasterizer.Draw(keyboard, &empty)) GTEST_SKIP() << "no host font";
VirtualKeyboardText text;
text.utf8 = "Hello world";
text.cursor_utf16 = 11;
text.revision = 1;
keyboard.SetText(text);
ASSERT_TRUE(rasterizer.Draw(keyboard, &typed));
// Text adds light glyph pixels; the empty field only has its caret.
EXPECT_GT(field_light_pixels(typed), field_light_pixels(empty) + 200);
text.password = true;
text.revision = 2;
keyboard.SetText(text);
ASSERT_TRUE(rasterizer.Draw(keyboard, &masked));
EXPECT_NE(masked, typed); // dots, not the letters
EXPECT_GT(field_light_pixels(masked), field_light_pixels(empty));
}
TEST(VirtualKeyboardTest, RasterizerFillsAnOpaqueTexture) {
VirtualKeyboard keyboard;
keyboard.Show(HeadAt(0.f, 1.6f, 0.f));
VirtualKeyboardRasterizer rasterizer;
std::vector<std::uint8_t> rgba;
(void)rasterizer.Draw(keyboard, &rgba); // labels depend on host fonts
ASSERT_EQ(rgba.size(), static_cast<std::size_t>(
VirtualKeyboard::kTextureWidth) *
VirtualKeyboard::kTextureHeight * 4);
for (std::size_t i = 3; i < rgba.size(); i += 4) ASSERT_EQ(rgba[i], 255);
// A key is lighter than the background behind it.
const auto& q = keyboard.keys()[FindKey(keyboard, "q")];
const int x = static_cast<int>(q.u0 * VirtualKeyboard::kTextureWidth) + 20;
const int y = static_cast<int>(q.v0 * VirtualKeyboard::kTextureHeight) + 20;
const std::size_t key_px =
(static_cast<std::size_t>(y) * VirtualKeyboard::kTextureWidth + x) * 4;
EXPECT_GT(rgba[key_px], rgba[0]);
}
} // namespace
} // namespace mocktail::vr