From b8e985329d7ffb229744acda9fed1e5d481e9838 Mon Sep 17 00:00:00 2001 From: MHunterG <48920434+MHunterG@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:21:19 +0300 Subject: [PATCH] Tilt a game's world by tilting the device A game played by tilting (LocoRoco tilts with L and R) can now be tilted with the motion sensors of a gamepad or of the phone itself. It is off by default and only for a game whose profile says how it tilts. - GameProfile::tilt names the buttons that tilt left and right, and whether the game eases its tilt towards the held side; then a small roll presses the button for part of the frames and tilts part way. - input/tilt: the roll from the accelerometer, fused with the gyroscope's rate; the dead zone with hysteresis, re-centring, inversion; three modes: Angle, Angle + level horizon, and Rate, where turning the device tilts and the tilt stays when the turning stops. Unit tested with synthetic readings (portablekit_tilt_tests). - input/motion: the sensors through SDL, a gamepad's first, else the device's own turned with the screen. The log says which were found, and why none were: Steam Input's virtual pad hides a Steam Deck's. - A tilt button the player presses takes over from the sensors, so a tilt never becomes both buttons at once. R3 alone re-centres, and so do the start and closing the menu. - Angle + level horizon turns the finished game picture against the device's roll and zooms it just enough to cover its rectangle at that angle (1:1 when level), capped at 10 degrees by default. The overlay and the interface are drawn after it and stay upright. - Controls > Tilt controls in the menu, settings.ini keys input.tilt*, and _TILT, _TILT_MODE, _TILT_FULL, _TILT_DEAD_ZONE. - The input script's virtual pad gets a gyroscope and an accelerometer when a script uses the new tilt and gyro steps, so all of it can be driven without hardware. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 11 ++ cmake/PortableKit.cmake | 6 +- docs/PROFILE_GUIDE.md | 2 + host/gpu/shaders/level.frag | 29 ++++ host/gpu/shaders/level.vert | 14 ++ host/gpu/vulkan_renderer.cpp | 316 ++++++++++++++++++++++++++++++++++- host/input/motion.cpp | 291 ++++++++++++++++++++++++++++++++ host/input/motion.hpp | 45 +++++ host/input/tilt.cpp | 128 ++++++++++++++ host/input/tilt.hpp | 135 +++++++++++++++ host/profile.hpp | 18 ++ host/settings/settings.cpp | 35 ++++ host/settings/settings.hpp | 10 ++ host/ui/input_script.cpp | 47 +++++- host/ui/input_script.hpp | 7 + host/ui/menu.cpp | 106 ++++++++++++ tests/tilt_tests.cpp | 221 ++++++++++++++++++++++++ 17 files changed, 1416 insertions(+), 5 deletions(-) create mode 100644 host/gpu/shaders/level.frag create mode 100644 host/gpu/shaders/level.vert create mode 100644 host/input/motion.cpp create mode 100644 host/input/motion.hpp create mode 100644 host/input/tilt.cpp create mode 100644 host/input/tilt.hpp create mode 100644 tests/tilt_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 63c819d..b612b11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,6 +345,17 @@ if(PSPRECOMP_BUILD_TESTS) add_test(NAME portablekit_profile_tests COMMAND portablekit_profile_tests) endif() +# Tilt controls: the device's roll from its motion sensors as a game's tilt +# buttons, with synthetic sensor readings. +if(PSPRECOMP_BUILD_TESTS) + add_executable(portablekit_tilt_tests + "${PORTABLEKIT_ROOT}/tests/tilt_tests.cpp" + "${PORTABLEKIT_ROOT}/host/input/tilt.cpp") + target_include_directories(portablekit_tilt_tests PRIVATE "${PORTABLEKIT_ROOT}/host") + set_target_properties(portablekit_tilt_tests PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + add_test(NAME portablekit_tilt_tests COMMAND portablekit_tilt_tests) +endif() + # Texture pack import: finding a pack in the layouts packs come in, the # checks made before copying, and the copy, swap and backup; and the PSP's DXT # block layout. No game data. diff --git a/cmake/PortableKit.cmake b/cmake/PortableKit.cmake index 4f87ea4..5d969d1 100644 --- a/cmake/PortableKit.cmake +++ b/cmake/PortableKit.cmake @@ -38,6 +38,7 @@ set(PORTABLEKIT_HOST_SOURCES host/gpu/texture_decode.cpp host/input/bindings.cpp host/input/touch_controls.cpp + host/input/tilt.cpp host/gpu/texture_pack.cpp host/gpu/texture_pack_import.cpp host/perf/frame_stats.cpp @@ -97,6 +98,7 @@ set(PORTABLEKIT_RENDERER_SOURCES host/gpu/vulkan_renderer.cpp host/ui/file_browser.cpp host/ui/font_menu.cpp + host/input/motion.cpp host/ui/input_script.cpp host/ui/layer.cpp host/ui/menu.cpp @@ -309,7 +311,9 @@ function(portablekit_add_game target) "kGeVertexShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.vert" "kGeRawVertexShader=GE_RAW_VERTICES@${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.vert" "kGeCheckVertexShader=GE_RAW_VERTICES,GE_CHECK_DECODE@${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.vert" - "kGeFragmentShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.frag") + "kGeFragmentShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/ge.frag" + "kLevelVertexShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/level.vert" + "kLevelFragmentShader=${PORTABLEKIT_ROOT}/host/gpu/shaders/level.frag") if(ANDROID) # Pre-rotation of the finished frame for a display turned sideways. list(APPEND shaders diff --git a/docs/PROFILE_GUIDE.md b/docs/PROFILE_GUIDE.md index 614630b..fe7dcab 100644 --- a/docs/PROFILE_GUIDE.md +++ b/docs/PROFILE_GUIDE.md @@ -63,6 +63,8 @@ Two more are for a port that goes further than the PSP did, and both are null un `trigger_profiles` are for how a game is played rather than how it is run: what the triggers may press instead of L and R, each with the key settings.ini keeps and the label the menu shows. Yakumo offers Bows (R on L2 to hold the aim, △ on R2 to shoot) and Bowguns (R and ○). Without any, the triggers press L and R and the menu has no row for them. +`tilt` is for a game played by tilting its world, as LocoRoco is with L and R: the buttons that tilt left and right, whether the game eases its tilt towards the held side (then a small roll of the device presses the button for part of the frames and tilts the world part of the way), and a sentence for the menu. With it, Controls gets a Tilt controls section, off by default, and the motion sensors of a gamepad (DualShock 4, DualSense, Switch Pro, a Steam Deck seen through SDL's own driver) or of the phone itself press those buttons (`host/input/tilt.hpp` for the arithmetic, `host/input/motion.hpp` for the sensors). Find the buttons and the easing with the game's pad trace before filling it in. Without it, nothing opens a sensor and the menu has no such section. + `player_name_label` and `default_player_name` are what the interface calls the name a game asks for and what it answers before the player sets one: "Player name" and "Player" unless a profile says otherwise. Yakumo says "Hunter name" and "Hunter". `glyph_cache` is for changing the font while a game runs. A game that caches the glyphs it has drawn keeps them until its atlas needs the cell, so a new font shows only in text it has not drawn yet. Where the game keeps that atlas, read off its own text code, lets the framework make it draw them again. Null for any game nobody has traced. diff --git a/host/gpu/shaders/level.frag b/host/gpu/shaders/level.frag new file mode 100644 index 0000000..3756666 --- /dev/null +++ b/host/gpu/shaders/level.frag @@ -0,0 +1,29 @@ +#version 450 + +// Level horizon: the game's picture turned about its centre and zoomed just +// enough to cover its rectangle, so no corner is ever left empty. + +layout(set = 0, binding = 0) uniform sampler2D frame; + +layout(push_constant) uniform Level { + // Where the picture lies in the frame image: offset and size, 0 to 1. + vec4 rect; + // cos and sin of the turn (anticlockwise on screen), the zoom, and the + // picture's width over its height. + vec4 turn; +} level; + +layout(location = 0) in vec2 picture; +layout(location = 0) out vec4 color; + +void main() { + // Centred, in units of the picture's height, y down as on screen. + vec2 point = (picture - 0.5) * vec2(level.turn.w, 1.0); + // The picture is turned anticlockwise as seen, which with y down is a + // clockwise turn of the coordinates: sample where this point came from. + float c = level.turn.x; + float s = level.turn.y; + vec2 source = vec2(c * point.x - s * point.y, s * point.x + c * point.y) / level.turn.z; + vec2 uv = source / vec2(level.turn.w, 1.0) + 0.5; + color = texture(frame, level.rect.xy + clamp(uv, 0.0, 1.0) * level.rect.zw); +} diff --git a/host/gpu/shaders/level.vert b/host/gpu/shaders/level.vert new file mode 100644 index 0000000..4858e45 --- /dev/null +++ b/host/gpu/shaders/level.vert @@ -0,0 +1,14 @@ +#version 450 + +// Level horizon (Angle + level horizon tilt controls): one triangle covering +// the picture's rectangle, which the viewport gives. The fragment shader +// turns and zooms the game's picture inside it. + +layout(location = 0) out vec2 picture; + +void main() { + // (0,0), (2,0), (0,2): the triangle that covers the whole viewport. + vec2 uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0); + picture = uv; +} diff --git a/host/gpu/vulkan_renderer.cpp b/host/gpu/vulkan_renderer.cpp index 43aa31e..33d6488 100644 --- a/host/gpu/vulkan_renderer.cpp +++ b/host/gpu/vulkan_renderer.cpp @@ -16,6 +16,7 @@ #include "perf/frame_stats.hpp" #include "perf/perf_overlay.hpp" #include "input/bindings.hpp" +#include "input/motion.hpp" #include "settings/settings.hpp" #if defined(PORTABLEKIT_ANDROID_APP) @@ -826,6 +827,35 @@ struct VulkanRenderer::Impl { void destroy_rotation_pipeline(); void record_rotation(VkCommandBuffer commands, std::uint32_t image_index, VkImageLayout layout); #endif + // Angle + level horizon (input/motion.hpp): the game frame is copied into + // one of these per swapchain image, then drawn turned and zoomed into the + // image shown, under the performance overlay and the interface. Made the + // first time the picture is turned, and with every swapchain after that. + struct LevelImage { + VkImage image{}; + VkDeviceMemory memory{}; + VkImageView view{}; + VkDescriptorSet set{}; + VkFramebuffer framebuffer{}; + }; + std::vector level_images; + VkRenderPass level_render_pass{}; + VkDescriptorSetLayout level_set_layout{}; + VkDescriptorPool level_pool{}; + VkPipelineLayout level_layout{}; + VkPipeline level_pipeline{}; + VkSampler level_sampler{}; + VkShaderModule level_vertex{}; + VkShaderModule level_fragment{}; + bool level_failed{}; + // Where the last game blit put the picture in its target. + VkOffset3D picture_low{}; + VkOffset3D picture_high{}; + bool create_level_pipeline(std::string &error); + bool ensure_level_images(); + void destroy_level_images(); + void destroy_level_pipeline(); + void record_level(VkCommandBuffer commands, std::uint32_t image_index, float degrees); bool ui_ready{}; ImDrawData *ui_draw_data{}; std::function event_hook; @@ -1684,6 +1714,7 @@ struct VulkanRenderer::Impl { #endif gamepad = device; gamepad_id = id; + input::motion::gamepad_changed(device); const char *name = SDL_GetGamepadName(device); const PadTuning tuning = pad_tuning(); std::cout << "[pad] " << (name != nullptr ? name : "gamepad") << " connected; confirm on " @@ -1723,6 +1754,7 @@ struct VulkanRenderer::Impl { void close_gamepad(SDL_JoystickID id) { if (gamepad == nullptr || id != gamepad_id) return; + input::motion::gamepad_changed(nullptr); SDL_CloseGamepad(gamepad); gamepad = nullptr; gamepad_id = 0; @@ -2822,6 +2854,7 @@ bool VulkanRenderer::Impl::create_swapchain(std::string &error) { } void VulkanRenderer::Impl::destroy_swapchain_views() { + destroy_level_images(); for (VkFramebuffer framebuffer : ui_framebuffers) vkDestroyFramebuffer(device, framebuffer, nullptr); ui_framebuffers.clear(); for (VkImageView view : swapchain_views) vkDestroyImageView(device, view, nullptr); @@ -3087,6 +3120,256 @@ void VulkanRenderer::Impl::record_rotation(VkCommandBuffer commands, std::uint32 } #endif +// The pass that draws the game's picture turned for Angle + level horizon. +bool VulkanRenderer::Impl::create_level_pipeline(std::string &error) { + if (level_pipeline != VK_NULL_HANDLE) return true; + VkAttachmentDescription attachment{}; + attachment.format = swapchain_format; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + // Cleared: the bars beside a picture of the PSP's shape stay black. + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + // Left as the plain blit leaves it, for the overlay and the interface. + attachment.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + attachment.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + VkAttachmentReference reference{0u, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1u; + subpass.pColorAttachments = &reference; + VkRenderPassCreateInfo pass_info{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + pass_info.attachmentCount = 1u; + pass_info.pAttachments = &attachment; + pass_info.subpassCount = 1u; + pass_info.pSubpasses = &subpass; + if (!check(vkCreateRenderPass(device, &pass_info, nullptr, &level_render_pass), "vkCreateRenderPass", error)) + return false; + + VkSamplerCreateInfo sampler_info{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + sampler_info.magFilter = VK_FILTER_LINEAR; + sampler_info.minFilter = VK_FILTER_LINEAR; + sampler_info.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + if (!check(vkCreateSampler(device, &sampler_info, nullptr, &level_sampler), "vkCreateSampler", error)) return false; + + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0u; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1u; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo set_info{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + set_info.bindingCount = 1u; + set_info.pBindings = &binding; + if (!check(vkCreateDescriptorSetLayout(device, &set_info, nullptr, &level_set_layout), + "vkCreateDescriptorSetLayout", error)) + return false; + constexpr std::uint32_t kMaxImages = 8u; + VkDescriptorPoolSize pool_size{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, kMaxImages}; + VkDescriptorPoolCreateInfo pool_info{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + pool_info.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + pool_info.maxSets = kMaxImages; + pool_info.poolSizeCount = 1u; + pool_info.pPoolSizes = &pool_size; + if (!check(vkCreateDescriptorPool(device, &pool_info, nullptr, &level_pool), "vkCreateDescriptorPool", error)) + return false; + + VkPushConstantRange push{VK_SHADER_STAGE_FRAGMENT_BIT, 0u, 8u * sizeof(float)}; + VkPipelineLayoutCreateInfo layout_info{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + layout_info.setLayoutCount = 1u; + layout_info.pSetLayouts = &level_set_layout; + layout_info.pushConstantRangeCount = 1u; + layout_info.pPushConstantRanges = &push; + if (!check(vkCreatePipelineLayout(device, &layout_info, nullptr, &level_layout), "vkCreatePipelineLayout", error)) + return false; + + const auto create_shader = [&](const std::uint32_t *code, std::size_t size, VkShaderModule &module) { + VkShaderModuleCreateInfo info{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + info.codeSize = size; + info.pCode = code; + return check(vkCreateShaderModule(device, &info, nullptr, &module), "vkCreateShaderModule", error); + }; + if (!create_shader(kLevelVertexShader, sizeof(kLevelVertexShader), level_vertex)) return false; + if (!create_shader(kLevelFragmentShader, sizeof(kLevelFragmentShader), level_fragment)) return false; + std::array stages{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = level_vertex; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = level_fragment; + stages[1].pName = "main"; + VkPipelineVertexInputStateCreateInfo vertex_input{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO}; + VkPipelineInputAssemblyStateCreateInfo assembly{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO}; + assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo viewport{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO}; + viewport.viewportCount = 1u; + viewport.scissorCount = 1u; + VkPipelineRasterizationStateCreateInfo raster{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO}; + raster.polygonMode = VK_POLYGON_MODE_FILL; + raster.cullMode = VK_CULL_MODE_NONE; + raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + raster.lineWidth = 1.0f; + VkPipelineMultisampleStateCreateInfo multisample{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO}; + multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineColorBlendAttachmentState blend_attachment{}; + blend_attachment.colorWriteMask = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo blend{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO}; + blend.attachmentCount = 1u; + blend.pAttachments = &blend_attachment; + const std::array dynamic_states{VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; + VkPipelineDynamicStateCreateInfo dynamic{VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO}; + dynamic.dynamicStateCount = static_cast(dynamic_states.size()); + dynamic.pDynamicStates = dynamic_states.data(); + VkGraphicsPipelineCreateInfo pipeline_info{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO}; + pipeline_info.stageCount = static_cast(stages.size()); + pipeline_info.pStages = stages.data(); + pipeline_info.pVertexInputState = &vertex_input; + pipeline_info.pInputAssemblyState = &assembly; + pipeline_info.pViewportState = &viewport; + pipeline_info.pRasterizationState = &raster; + pipeline_info.pMultisampleState = &multisample; + pipeline_info.pColorBlendState = &blend; + pipeline_info.pDynamicState = &dynamic; + pipeline_info.layout = level_layout; + pipeline_info.renderPass = level_render_pass; + return check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1u, &pipeline_info, nullptr, &level_pipeline), + "vkCreateGraphicsPipelines", error); +} + +// One copy of the frame per swapchain image, made for the swapchain in use. +// A failure is reported once, and the picture is then simply not turned. +bool VulkanRenderer::Impl::ensure_level_images() { + if (level_failed) return false; + if (!level_images.empty() && level_images.size() == swapchain_images.size()) return true; + std::string error; + const auto fail = [&] { + std::cout << "[render] cannot turn the picture for the level horizon: " << error << std::endl; + destroy_level_images(); + level_failed = true; + return false; + }; + if (!create_level_pipeline(error)) return fail(); + level_images.resize(swapchain_images.size()); + for (std::size_t i = 0; i < level_images.size(); ++i) { + LevelImage &level = level_images[i]; + if (!create_image(swapchain_extent.width, swapchain_extent.height, swapchain_format, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, level.image, level.memory, + level.view, VK_IMAGE_ASPECT_COLOR_BIT, error)) + return fail(); + VkDescriptorSetAllocateInfo allocate{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + allocate.descriptorPool = level_pool; + allocate.descriptorSetCount = 1u; + allocate.pSetLayouts = &level_set_layout; + if (!check(vkAllocateDescriptorSets(device, &allocate, &level.set), "vkAllocateDescriptorSets", error)) + return fail(); + VkDescriptorImageInfo image_info{level_sampler, level.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = level.set; + write.dstBinding = 0u; + write.descriptorCount = 1u; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &image_info; + vkUpdateDescriptorSets(device, 1u, &write, 0u, nullptr); + // The pass draws where the frame is finished: the swapchain image, or + // on a phone turned sideways its upright stand-in. + VkImageView target = swapchain_views[i]; +#if defined(__ANDROID__) + if (prerotated() && i < upright_images.size()) target = upright_images[i].view; +#endif + VkFramebufferCreateInfo framebuffer_info{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; + framebuffer_info.renderPass = level_render_pass; + framebuffer_info.attachmentCount = 1u; + framebuffer_info.pAttachments = ⌖ + framebuffer_info.width = swapchain_extent.width; + framebuffer_info.height = swapchain_extent.height; + framebuffer_info.layers = 1u; + if (!check(vkCreateFramebuffer(device, &framebuffer_info, nullptr, &level.framebuffer), "vkCreateFramebuffer", + error)) + return fail(); + } + return true; +} + +void VulkanRenderer::Impl::destroy_level_images() { + for (LevelImage &level : level_images) { + if (level.framebuffer != VK_NULL_HANDLE) vkDestroyFramebuffer(device, level.framebuffer, nullptr); + if (level.set != VK_NULL_HANDLE) vkFreeDescriptorSets(device, level_pool, 1u, &level.set); + if (level.view != VK_NULL_HANDLE) vkDestroyImageView(device, level.view, nullptr); + if (level.image != VK_NULL_HANDLE) vkDestroyImage(device, level.image, nullptr); + if (level.memory != VK_NULL_HANDLE) vkFreeMemory(device, level.memory, nullptr); + } + level_images.clear(); +} + +void VulkanRenderer::Impl::destroy_level_pipeline() { + destroy_level_images(); + if (level_pipeline != VK_NULL_HANDLE) vkDestroyPipeline(device, level_pipeline, nullptr); + if (level_layout != VK_NULL_HANDLE) vkDestroyPipelineLayout(device, level_layout, nullptr); + if (level_pool != VK_NULL_HANDLE) vkDestroyDescriptorPool(device, level_pool, nullptr); + if (level_set_layout != VK_NULL_HANDLE) vkDestroyDescriptorSetLayout(device, level_set_layout, nullptr); + if (level_sampler != VK_NULL_HANDLE) vkDestroySampler(device, level_sampler, nullptr); + if (level_vertex != VK_NULL_HANDLE) vkDestroyShaderModule(device, level_vertex, nullptr); + if (level_fragment != VK_NULL_HANDLE) vkDestroyShaderModule(device, level_fragment, nullptr); + if (level_render_pass != VK_NULL_HANDLE) vkDestroyRenderPass(device, level_render_pass, nullptr); + level_pipeline = VK_NULL_HANDLE; + level_layout = VK_NULL_HANDLE; + level_pool = VK_NULL_HANDLE; + level_set_layout = VK_NULL_HANDLE; + level_sampler = VK_NULL_HANDLE; + level_vertex = VK_NULL_HANDLE; + level_fragment = VK_NULL_HANDLE; + level_render_pass = VK_NULL_HANDLE; +} + +// Draws the copied frame into the image shown, turned `degrees` +// anticlockwise about the picture's centre and zoomed just enough that the +// turned picture still covers its whole rectangle: for a W x H picture that +// is the larger of cos a + (H/W) sin a and cos a + (W/H) sin a, which is 1 +// when level and grows smoothly with the angle. +void VulkanRenderer::Impl::record_level(VkCommandBuffer commands, std::uint32_t image_index, float degrees) { + const LevelImage &level = level_images[image_index]; + transition(commands, level.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + const float width = static_cast(std::max(picture_high.x - picture_low.x, 1)); + const float height = static_cast(std::max(picture_high.y - picture_low.y, 1)); + const float radians = degrees * 3.14159265358979f / 180.0f; + const float c = std::cos(radians); + const float s = std::fabs(std::sin(radians)); + const float zoom = std::max(c + height / width * s, c + width / height * s); + const float push[8] = {static_cast(picture_low.x) / static_cast(swapchain_extent.width), + static_cast(picture_low.y) / static_cast(swapchain_extent.height), + width / static_cast(swapchain_extent.width), + height / static_cast(swapchain_extent.height), + c, + std::sin(radians), + zoom, + width / height}; + const VkClearValue black{{{0.0f, 0.0f, 0.0f, 1.0f}}}; + VkRenderPassBeginInfo pass{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; + pass.renderPass = level_render_pass; + pass.framebuffer = level.framebuffer; + pass.renderArea = {{0, 0}, swapchain_extent}; + pass.clearValueCount = 1u; + pass.pClearValues = &black; + vkCmdBeginRenderPass(commands, &pass, VK_SUBPASS_CONTENTS_INLINE); + perf::count_render_pass(); + const VkViewport viewport{static_cast(picture_low.x), static_cast(picture_low.y), width, height, + 0.0f, 1.0f}; + const VkRect2D scissor{{picture_low.x, picture_low.y}, + {static_cast(width), static_cast(height)}}; + vkCmdSetViewport(commands, 0u, 1u, &viewport); + vkCmdSetScissor(commands, 0u, 1u, &scissor); + vkCmdBindPipeline(commands, VK_PIPELINE_BIND_POINT_GRAPHICS, level_pipeline); + vkCmdBindDescriptorSets(commands, VK_PIPELINE_BIND_POINT_GRAPHICS, level_layout, 0u, 1u, &level.set, 0u, nullptr); + vkCmdPushConstants(commands, level_layout, VK_SHADER_STAGE_FRAGMENT_BIT, 0u, sizeof(push), push); + vkCmdDraw(commands, 3u, 1u, 0u, 0u); + vkCmdEndRenderPass(commands); +} + bool VulkanRenderer::Impl::create_ui_framebuffers(std::string &error) { std::vector views = swapchain_views; #if defined(__ANDROID__) @@ -3143,6 +3426,8 @@ void VulkanRenderer::Impl::record_game_blit(VkCommandBuffer commands, VkImage so VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); } } + picture_low = low; + picture_high = high; VkImageBlit blit{}; blit.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0u, 0u, 1u}; blit.srcOffsets[1] = {static_cast(target_extent.width), @@ -3217,7 +3502,17 @@ void VulkanRenderer::Impl::submit_and_present(VkCommandBuffer commands, VkFence if (source != VK_NULL_HANDLE) { transition(commands, source, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); - record_game_blit(commands, source, target); + // Angle + level horizon turns the picture; everything drawn after + // it (the overlay, the interface) stays upright. + const float level = input::motion::level_degrees(); + if (std::fabs(level) > 0.01f && ensure_level_images() && image_index < level_images.size()) { + transition(commands, level_images[image_index].image, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + record_game_blit(commands, source, level_images[image_index].image); + record_level(commands, image_index, level); + } else { + record_game_blit(commands, source, target); + } transition(commands, source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); } else { @@ -4754,6 +5049,18 @@ void VulkanRenderer::Impl::sample_pad(bool focused) { pad.analog_x = static_cast(std::clamp(0x80 + analog_x, 0, 255)); pad.analog_y = static_cast(std::clamp(0x80 + analog_y, 0, 255)); + // Tilt controls (input/motion.hpp): the motion sensors press the game's + // tilt buttons. A tilt button the player presses takes over, so tilting + // one way while pressing the other shoulder never becomes both at once + // (LocoRoco's jump). R3 alone re-centres; L3 with it opens the menu. + if (const TiltControls *tilt = portablekit::game().tilt; tilt != nullptr) { + SDL_Gamepad *const device = impl_->gamepad; + const bool recenter = device != nullptr && SDL_GetGamepadButton(device, SDL_GAMEPAD_BUTTON_RIGHT_STICK) && + !SDL_GetGamepadButton(device, SDL_GAMEPAD_BUTTON_LEFT_STICK); + const std::uint32_t tilted = input::motion::sample(device, impl_->window, recenter); + if ((pad.buttons & (tilt->left | tilt->right)) == 0u) pad.buttons |= tilted; + } + // Unattended runs (overlay bootstrapping) press confirm periodically so the // game walks through title screens and dialogs on its own. static const std::uint64_t auto_confirm = [] { @@ -4824,7 +5131,11 @@ void VulkanRenderer::set_event_hook(std::function hook) void VulkanRenderer::set_game_input(bool enabled) { if (!impl_) return; - if (enabled && !impl_->game_input) impl_->suppress_held = true; + if (enabled && !impl_->game_input) { + impl_->suppress_held = true; + // Back from the menu, however the device is held now is level. + input::motion::recenter(); + } if (!enabled) impl_->touch.release_all(); impl_->game_input = enabled; } @@ -7614,6 +7925,7 @@ void VulkanRenderer::shutdown() { if (impl.ui_ready && ImGui::GetCurrentContext() != nullptr) ImGui_ImplVulkan_Shutdown(); impl.ui_ready = false; impl.destroy_swapchain_views(); + impl.destroy_level_pipeline(); #if defined(__ANDROID__) impl.destroy_rotation_pipeline(); #endif diff --git a/host/input/motion.cpp b/host/input/motion.cpp new file mode 100644 index 0000000..5d6eb70 --- /dev/null +++ b/host/input/motion.cpp @@ -0,0 +1,291 @@ +#include "../profile.hpp" +#include "input/motion.hpp" + +#include "input/tilt.hpp" +#include "settings/settings.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace portablekit::input::motion { +namespace { + +constexpr float kDegreesPerRadian = 57.29577951308232f; +// The tilt buttons change at most once per PSP frame when pressed part of the +// time (TiltControls::proportional). +constexpr std::uint64_t kPulseNs = 16'683'000u; + +struct State { + SDL_Gamepad *pad{}; + bool pad_accel{}; + bool pad_gyro{}; + bool pad_enabled{}; + std::string pad_name; + // The device's own sensors, opened the first time they are needed. + bool device_opened{}; + SDL_Sensor *device_accel{}; + SDL_Sensor *device_gyro{}; + std::string device_names; + + bool on{}; + bool reading{}; + bool recenter_down{}; + tilt::RollEstimator estimator; + tilt::Mapper mapper; + tilt::Pulse pulse; + tilt::Output output; + std::uint64_t last_ns{}; + std::uint64_t pulse_ns{}; + bool pulse_down{}; + int traced_direction{}; + std::uint64_t traced_ns{}; +}; + +State &state() { + static State value; + return value; +} + +bool trace() { + static const bool value = portablekit::env("TRACE_PAD") != nullptr || portablekit::env("TRACE_TILT") != nullptr; + return value; +} + +std::string pad_label(SDL_Gamepad *pad) { + const char *name = SDL_GetGamepadName(pad); + return name != nullptr ? name : "gamepad"; +} + +void enable_pad_sensors(State &s, bool enable) { + if (s.pad == nullptr || s.pad_enabled == enable) return; + if (s.pad_accel) SDL_SetGamepadSensorEnabled(s.pad, SDL_SENSOR_ACCEL, enable); + if (s.pad_gyro) SDL_SetGamepadSensorEnabled(s.pad, SDL_SENSOR_GYRO, enable); + s.pad_enabled = enable; +} + +// A phone's sensors, and a computer's where it has any. Opened once, when +// tilt controls are first on without a gamepad that has its own. +void open_device_sensors(State &s) { + if (s.device_opened) return; + s.device_opened = true; + if (!SDL_InitSubSystem(SDL_INIT_SENSOR)) { + std::cout << "[tilt] no sensor support: " << SDL_GetError() << std::endl; + return; + } + int count = 0; + SDL_SensorID *ids = SDL_GetSensors(&count); + for (int i = 0; ids != nullptr && i < count; ++i) { + const SDL_SensorType type = SDL_GetSensorTypeForID(ids[i]); + SDL_Sensor **slot = type == SDL_SENSOR_ACCEL ? &s.device_accel : type == SDL_SENSOR_GYRO ? &s.device_gyro + : nullptr; + if (slot == nullptr || *slot != nullptr) continue; + *slot = SDL_OpenSensor(ids[i]); + if (*slot == nullptr) { + std::cout << "[tilt] cannot open sensor " << ids[i] << ": " << SDL_GetError() << std::endl; + continue; + } + const char *name = SDL_GetSensorName(*slot); + if (!s.device_names.empty()) s.device_names += ", "; + s.device_names += std::string(name != nullptr ? name : "sensor") + + (type == SDL_SENSOR_ACCEL ? " (accelerometer)" : " (gyroscope)"); + } + SDL_free(ids); + if (s.device_names.empty()) + std::cout << "[tilt] this device has no accelerometer or gyroscope of its own" << std::endl; + else + std::cout << "[tilt] device sensors: " << s.device_names << std::endl; +} + +// Quarter turns anticlockwise of the display from the device's natural +// orientation, which is the frame a device's own sensors report in. +int display_quarter_turns(SDL_Window *window) { + if (window == nullptr) return 0; + const SDL_DisplayID display = SDL_GetDisplayForWindow(window); + if (display == 0) return 0; + const auto turns = [](SDL_DisplayOrientation orientation) { + switch (orientation) { + case SDL_ORIENTATION_LANDSCAPE: return 1; // right side up + case SDL_ORIENTATION_PORTRAIT_FLIPPED: return 2; + case SDL_ORIENTATION_LANDSCAPE_FLIPPED: return 3; // left side up + default: return 0; + } + }; + const SDL_DisplayOrientation current = SDL_GetCurrentDisplayOrientation(display); + if (current == SDL_ORIENTATION_UNKNOWN) return 0; + return (turns(current) - turns(SDL_GetNaturalDisplayOrientation(display)) + 4) % 4; +} + +// A vector in the device's natural axes, in the screen's. +void to_screen(float v[3], int quarter_turns) { + const float x = v[0]; + const float y = v[1]; + switch (quarter_turns) { + case 1: v[0] = -y; v[1] = x; break; + case 2: v[0] = -x; v[1] = -y; break; + case 3: v[0] = y; v[1] = -x; break; + default: break; + } +} + +// The pad's sensors when it has them, else the device's. +bool read_sensors(State &s, SDL_Window *window, tilt::Reading &reading) { + if (s.pad != nullptr && (s.pad_accel || s.pad_gyro)) { + reading.has_accel = s.pad_accel && SDL_GetGamepadSensorData(s.pad, SDL_SENSOR_ACCEL, reading.accel, 3); + reading.has_gyro = s.pad_gyro && SDL_GetGamepadSensorData(s.pad, SDL_SENSOR_GYRO, reading.gyro, 3); + return reading.has_accel || reading.has_gyro; + } + open_device_sensors(s); + if (s.device_accel == nullptr && s.device_gyro == nullptr) return false; + const int turns = display_quarter_turns(window); + if (s.device_accel != nullptr) { + reading.has_accel = SDL_GetSensorData(s.device_accel, reading.accel, 3); + if (reading.has_accel) to_screen(reading.accel, turns); + } + if (s.device_gyro != nullptr) { + reading.has_gyro = SDL_GetSensorData(s.device_gyro, reading.gyro, 3); + if (reading.has_gyro) to_screen(reading.gyro, turns); + } + return reading.has_accel || reading.has_gyro; +} + +tilt::Tuning tuning_now() { + const settings::Settings &player = settings::current(); + tilt::Tuning tuning; + tuning.mode = player.tilt_mode; + tuning.full_tilt = player.tilt_full; + tuning.dead_zone = player.tilt_dead_zone; + tuning.invert = player.tilt_invert; + tuning.level_limit = player.tilt_level_limit; + return tuning; +} + +} // namespace + +void gamepad_changed(SDL_Gamepad *pad) { + State &s = state(); + if (s.pad == pad) return; + s.pad = pad; + s.pad_enabled = false; + s.pad_accel = pad != nullptr && SDL_GamepadHasSensor(pad, SDL_SENSOR_ACCEL); + s.pad_gyro = pad != nullptr && SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO); + s.pad_name = pad != nullptr ? pad_label(pad) : std::string{}; + // Another sensor, another neutral. + s.estimator.reset(); + s.mapper.recenter(); + if (portablekit::game().tilt == nullptr || pad == nullptr) return; + if (s.pad_accel || s.pad_gyro) { + std::cout << "[tilt] " << s.pad_name << ": " + << (s.pad_gyro && s.pad_accel ? "gyroscope and accelerometer" + : s.pad_gyro ? "gyroscope only" + : "accelerometer only") + << " (" << SDL_GetGamepadSensorDataRate(pad, s.pad_gyro ? SDL_SENSOR_GYRO : SDL_SENSOR_ACCEL) + << " Hz)" << std::endl; + } else { + std::cout << "[tilt] " << s.pad_name << " has no motion sensors"; + // Steam Input hands the game a virtual pad without them, the Steam + // Deck's own included. + const SDL_GamepadType type = SDL_GetGamepadType(pad); + if (s.pad_name.find("Steam") != std::string::npos || type == SDL_GAMEPAD_TYPE_XBOX360) + std::cout << "; under Steam Input, turn Steam Input off for this game to use the pad's own gyroscope, " + "or map the gyroscope to a stick in Steam's controller settings"; + std::cout << std::endl; + } +} + +std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button) { + State &s = state(); + const TiltControls *controls = portablekit::game().tilt; + const bool on = controls != nullptr && settings::current().tilt; + if (pad != s.pad) gamepad_changed(pad); + if (!on) { + if (s.on) { + enable_pad_sensors(s, false); + s.on = false; + s.reading = false; + s.output = {}; + } + return 0u; + } + if (!s.on) { + s.on = true; + s.estimator.reset(); + s.mapper.recenter(); + s.last_ns = 0u; + } + enable_pad_sensors(s, true); + + if (recenter_button && !s.recenter_down) { + s.mapper.recenter(); + if (trace()) std::cout << "[tilt] re-centred by the gamepad" << std::endl; + } + s.recenter_down = recenter_button; + + const std::uint64_t now = SDL_GetTicksNS(); + const float seconds = s.last_ns == 0u ? 0.0f : static_cast(now - s.last_ns) * 1e-9f; + s.last_ns = now; + tilt::Reading reading; + s.reading = read_sensors(s, window, reading); + if (!s.reading) { + s.output = {}; + return 0u; + } + s.estimator.update(reading, seconds); + if (!s.estimator.valid()) return 0u; + const tilt::Tuning tuning = tuning_now(); + s.output = s.mapper.update(s.estimator.roll(), s.estimator.rate(), seconds, tuning); + + bool down = s.output.direction != 0; + if (controls->proportional) { + if (s.output.direction != s.traced_direction || now - s.pulse_ns >= kPulseNs) { + s.pulse_down = s.pulse.step(s.output.direction, s.output.strength, true); + s.pulse_ns = now; + } + down = s.pulse_down; + } + if (trace() && (s.output.direction != s.traced_direction || now - s.traced_ns >= 1'000'000'000u)) { + std::cout << "[tilt] roll " << std::lround(s.estimator.roll() * 10.0f) / 10.0f << " neutral " + << std::lround(s.mapper.neutral() * 10.0f) / 10.0f << " rate " << std::lround(s.estimator.rate()) + << " deg/s -> " + << (s.output.direction < 0 ? "left" : s.output.direction > 0 ? "right" : "none") + << " strength " << std::lround(s.output.strength * 100.0f) << "%"; + if (tuning.mode == tilt::Mode::AngleLevel) std::cout << " level " << s.output.level_degrees; + std::cout << std::endl; + s.traced_ns = now; + } + s.traced_direction = s.output.direction; + if (!down) return 0u; + return s.output.direction < 0 ? controls->left : controls->right; +} + +void recenter() { state().mapper.recenter(); } + +float level_degrees() { + const State &s = state(); + return s.on && s.reading ? s.output.level_degrees : 0.0f; +} + +std::string source() { + State &s = state(); + if (s.pad != nullptr && (s.pad_accel || s.pad_gyro)) + return s.pad_name + (s.pad_gyro && s.pad_accel ? ": gyroscope and accelerometer" + : s.pad_gyro ? ": gyroscope only" + : ": accelerometer only"); + if (!s.device_names.empty()) return "This device: " + s.device_names; + if (s.pad != nullptr) return s.pad_name + " has no motion sensors"; + if (s.device_opened) return "No motion sensors found"; + return "Not read yet"; +} + +bool reading(float &roll_from_neutral) { + const State &s = state(); + if (!s.on || !s.reading || !s.estimator.valid()) return false; + roll_from_neutral = s.estimator.roll() - s.mapper.neutral(); + return true; +} + +} // namespace portablekit::input::motion diff --git a/host/input/motion.hpp b/host/input/motion.hpp new file mode 100644 index 0000000..0e709af --- /dev/null +++ b/host/input/motion.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +struct SDL_Gamepad; +struct SDL_Window; + +// Motion sensors for tilt controls (input/tilt.hpp), through SDL: the +// gyroscope and accelerometer of the gamepad in use (DualShock 4, DualSense, +// Switch Pro, a Steam Deck seen through SDL's own driver), or else the +// device's own (a phone or a tablet). Only for a game with GameProfile::tilt, +// and only while the player has tilt controls on; otherwise the sensors stay +// closed. +// +// The main thread only, like the pad it is read with. +namespace portablekit::input::motion { + +// The renderer took a gamepad, or let it go (null). The log says which motion +// sensors it has, and why there are none when there are none. +void gamepad_changed(SDL_Gamepad *pad); + +// Reads the sensors and returns the game's tilt buttons (SceCtrlButtons bits) +// to press now: 0 while tilt controls are off, the game does not tilt, or no +// sensor can be read. `recenter_button` is the gamepad's re-centre button +// (R3); its press makes the current hold neutral. +[[nodiscard]] std::uint32_t sample(SDL_Gamepad *pad, SDL_Window *window, bool recenter_button); + +// The next reading becomes neutral. Called when the game gets its input back +// from the menu, and by the menu's Re-centre. +void recenter(); + +// Angle + level horizon: degrees to turn the game's picture anticlockwise so +// that its horizon stays level with the real one. 0 in every other case. +[[nodiscard]] float level_degrees(); + +// For the menu: where the tilt comes from ("DualSense Wireless Controller: +// gyroscope and accelerometer"), or why it cannot work. +[[nodiscard]] std::string source(); + +// For the menu: the roll from neutral now, in degrees, and whether any +// sensor is being read. +[[nodiscard]] bool reading(float &roll_from_neutral); + +} // namespace portablekit::input::motion diff --git a/host/input/tilt.cpp b/host/input/tilt.cpp new file mode 100644 index 0000000..2eba89b --- /dev/null +++ b/host/input/tilt.cpp @@ -0,0 +1,128 @@ +#include "input/tilt.hpp" + +#include +#include + +namespace portablekit::input::tilt { +namespace { + +constexpr float kDegrees = 57.29577951308232f; +constexpr float kGravity = 9.80665f; +// Seconds over which the accelerometer corrects the gyroscope's drift. +constexpr float kFusionSeconds = 0.25f; +// Seconds of smoothing on the accelerometer alone, when there is no gyroscope. +constexpr float kAccelOnlySeconds = 0.08f; + +float blend(float seconds, float time_constant) { + if (seconds <= 0.0f) return 0.0f; + return seconds / (time_constant + seconds); +} + +} // namespace + +void RollEstimator::update(const Reading &reading, float seconds) { + seconds = std::clamp(seconds, 0.0f, 0.25f); + bool have_accel_roll = false; + float accel_roll = 0.0f; + if (reading.has_accel) { + const float x = reading.accel[0]; + const float y = reading.accel[1]; + const float z = reading.accel[2]; + const float length = std::sqrt(x * x + y * y + z * z); + // Free fall or a sensor still starting says nothing about "up". + if (length > 0.3f * kGravity) { + up_[0] = x / length; + up_[1] = y / length; + up_[2] = z / length; + accel_roll = std::asin(std::clamp(-up_[0], -1.0f, 1.0f)) * kDegrees; + have_accel_roll = true; + } + } + bool have_gyro_rate = false; + float gyro_rate = 0.0f; + if (reading.has_gyro) { + // The X axis's height above the horizon is asin(x·up); up turns in + // the device's axes as -ω × up, so the height changes at + // -ω·(up × x) / cos(height), and roll is minus the height. + const float cosine = std::sqrt(std::max(1.0f - up_[0] * up_[0], 0.0f)); + if (cosine > 0.2f) { + gyro_rate = (reading.gyro[1] * up_[2] - reading.gyro[2] * up_[1]) / cosine * kDegrees; + have_gyro_rate = true; + } + } + if (!valid_) { + if (!have_accel_roll && !have_gyro_rate) return; + // Without an accelerometer the start is taken as level; re-centring + // makes it so anyway. + roll_ = have_accel_roll ? accel_roll : 0.0f; + rate_ = have_gyro_rate ? gyro_rate : 0.0f; + valid_ = true; + return; + } + const float previous = roll_; + if (have_gyro_rate) { + roll_ += gyro_rate * seconds; + if (have_accel_roll) roll_ += (accel_roll - roll_) * blend(seconds, kFusionSeconds); + rate_ = gyro_rate; + } else if (have_accel_roll) { + roll_ += (accel_roll - roll_) * blend(seconds, kAccelOnlySeconds); + rate_ = seconds > 0.0f ? (roll_ - previous) / seconds : 0.0f; + } + roll_ = std::clamp(roll_, -90.0f, 90.0f); +} + +Output Mapper::update(float roll, float rate, float seconds, const Tuning &tuning) { + const float full = std::clamp(tuning.full_tilt, kMinFullTilt, kMaxFullTilt); + const float dead = std::clamp(tuning.dead_zone, kMinDeadZone, std::min(kMaxDeadZone, full - 1.0f)); + if (recenter_) { + recenter_ = false; + neutral_ = roll; + accumulated_ = 0.0f; + held_ = 0; + } + Output out; + const float from_neutral = roll - neutral_; + float angle = from_neutral; + if (tuning.mode == Mode::Rate) { + // Only turning faster than holding still counts, by how much faster. + const float speed = std::fabs(rate); + if (speed > kRateThreshold) accumulated_ += std::copysign(speed - kRateThreshold, rate) * seconds; + if (seconds > 0.0f) accumulated_ *= std::exp(-seconds / kRateDecaySeconds); + // No further than a little past full, so tilting back acts at once. + accumulated_ = std::clamp(accumulated_, -full * 1.25f, full * 1.25f); + angle = accumulated_; + } else if (tuning.mode == Mode::AngleLevel) { + const float limit = std::clamp(tuning.level_limit, kMinLevelLimit, kMaxLevelLimit); + out.level_degrees = std::clamp(from_neutral, -limit, limit); + } + if (tuning.invert) angle = -angle; + + const float magnitude = std::fabs(angle); + const int side = angle > 0.0f ? 1 : -1; + const float release = std::max(dead - kHysteresis, dead * 0.5f); + if (held_ != 0 && (side != held_ || magnitude < release)) held_ = 0; + if (held_ == 0 && magnitude > dead) held_ = side; + out.direction = held_; + if (held_ != 0) out.strength = std::clamp((magnitude - dead) / (full - dead), 0.0f, 1.0f); + return out; +} + +bool Pulse::step(int direction, float strength, bool proportional) { + if (direction != direction_) { + direction_ = direction; + // A new press starts with the button down. + phase_ = 1.0f; + } + if (direction == 0) return false; + if (!proportional) return true; + // Just past the dead zone still presses one frame in five. + const float duty = std::clamp(0.2f + 0.8f * strength, 0.2f, 1.0f); + phase_ += duty; + if (phase_ >= 1.0f) { + phase_ -= 1.0f; + return true; + } + return false; +} + +} // namespace portablekit::input::tilt diff --git a/host/input/tilt.hpp b/host/input/tilt.hpp new file mode 100644 index 0000000..c1035c3 --- /dev/null +++ b/host/input/tilt.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include + +// Tilt controls: the device's roll, from its motion sensors, as a game's +// tilt buttons (LocoRoco tilts its world with L and R). Plain arithmetic, no +// SDL, so it is tested without hardware; input/motion.hpp reads the sensors. +// +// Axes are the screen's, as SDL gives them for a gamepad: +X to the right, +// +Y up, +Z towards the player. The accelerometer reads the reaction to +// gravity (a device at rest reads +9.8 m/s² straight up), the gyroscope +// radians a second, anticlockwise positive looking down each axis. +// +// Roll is the angle of the device's X axis below the horizon, in degrees: +// positive with the right side down. Measured that way it does not depend on +// how far the device is pitched towards the player, so a gamepad held flat +// and a phone or a Steam Deck held upright read the same. +namespace portablekit::input::tilt { + +enum class Mode { + // The roll from the calibrated neutral presses the tilt buttons. + Angle, + // As Angle, and the picture is turned against the roll so that the + // game's horizon stays level with the real one (presentation only). + AngleLevel, + // Turning the device tilts: its rate of roll is added up into a tilt that + // stays when the turning stops and fades back slowly. Turning slower than + // kRateThreshold adds nothing, so the device can be brought back to a + // comfortable hold without losing the tilt. + Rate, +}; + +struct Tuning { + Mode mode{Mode::Angle}; + // Roll, in degrees from neutral, at which the tilt is full. + float full_tilt{12.0f}; + // Roll, in degrees from neutral, before anything is pressed. + float dead_zone{4.0f}; + // Roll left presses right, and the other way round. + bool invert{}; + // Angle + level horizon: the picture is turned at most this far, in + // degrees; the further it turns, the more it is zoomed to cover the + // screen, so the cap keeps the crop small. + float level_limit{10.0f}; +}; + +inline constexpr float kMinFullTilt = 5.0f; +inline constexpr float kMaxFullTilt = 45.0f; +inline constexpr float kMinDeadZone = 1.0f; +inline constexpr float kMaxDeadZone = 15.0f; +// A pressed side is released only this far inside the dead zone, so a hand +// resting at its edge does not flicker the button. +inline constexpr float kHysteresis = 1.5f; +// Rate mode: degrees a second of roll that count as holding still. +inline constexpr float kRateThreshold = 20.0f; +// Rate mode: seconds for the added-up tilt to fall to a third by itself. +inline constexpr float kRateDecaySeconds = 3.0f; +inline constexpr float kMinLevelLimit = 2.0f; +inline constexpr float kMaxLevelLimit = 20.0f; + +// One reading of the sensors. Either may be missing: a pad with only a +// gyroscope, a phone with only an accelerometer. +struct Reading { + bool has_accel{}; + float accel[3]{}; // m/s², screen axes + bool has_gyro{}; + float gyro[3]{}; // rad/s, screen axes +}; + +// Fuses the accelerometer's roll, steady but shaken by every movement, with +// the gyroscope's rate of roll, smooth but drifting (a complementary filter). +class RollEstimator { +public: + // Takes a reading `seconds` after the previous one. + void update(const Reading &reading, float seconds); + void reset() { *this = RollEstimator{}; } + [[nodiscard]] bool valid() const { return valid_; } + // Degrees, right side down positive. + [[nodiscard]] float roll() const { return roll_; } + // Degrees a second, the same sign. + [[nodiscard]] float rate() const { return rate_; } + +private: + bool valid_{}; + float roll_{}; + float rate_{}; + // The last direction of "up" in the device's axes; straight up until an + // accelerometer says otherwise. + float up_[3]{0.0f, 1.0f, 0.0f}; +}; + +// What the roll asks of the game. +struct Output { + // -1 tilts left, +1 right, 0 neither. + int direction{}; + // How far past the dead zone, 0 to 1 (1 at full tilt). + float strength{}; + // For Angle + level horizon: degrees to turn the picture anticlockwise, + // which is the roll from neutral up to Tuning::level_limit. 0 in the + // other modes. + float level_degrees{}; +}; + +// The roll, from its neutral, as a direction to tilt: the dead zone, the +// hysteresis, inversion, re-centring and the rate mode. +class Mapper { +public: + // The next roll taken becomes neutral (and Rate's tilt drops to none). + void recenter() { recenter_ = true; } + [[nodiscard]] Output update(float roll, float rate, float seconds, const Tuning &tuning); + [[nodiscard]] float neutral() const { return neutral_; } + +private: + bool recenter_{true}; + float neutral_{}; + float accumulated_{}; + int held_{}; +}; + +// Presses one of the tilt buttons for a direction and a strength, a frame at +// a time. At full strength the button is held; below it the button is held +// for that share of the frames, spread evenly, so a game that eases its tilt +// towards the pressed side sees part of the tilt. Strength 0 with a +// direction still presses a little, the dead zone having been passed. +class Pulse { +public: + // True when the button is down this frame. + [[nodiscard]] bool step(int direction, float strength, bool proportional); + +private: + float phase_{}; + int direction_{}; +}; + +} // namespace portablekit::input::tilt diff --git a/host/profile.hpp b/host/profile.hpp index 8ffdb28..ae838f5 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -45,6 +45,21 @@ struct TriggerProfile { std::uint32_t right; // and R2 }; +// A game played by tilting its world (LocoRoco tilts with L and R): what the +// device's roll presses when the player turns tilt controls on +// (input/tilt.hpp, input/motion.hpp). +struct TiltControls { + std::uint32_t left; // the PSP buttons held to tilt left, as SceCtrlButtons bits + std::uint32_t right; // and right + // True when the game eases its tilt towards the held side, so pressing + // a button part of the time tilts it part of the way: the roll between + // the dead zone and a full tilt then presses for that share of frames. + // False: past the dead zone the button is simply held. + bool proportional; + // What the menu adds about the game's own controls, a whole sentence. + const char *note; +}; + // Another release of the game than the one a port supports, and what the // installer tells a player who hands it that one. struct OtherRelease { @@ -203,6 +218,9 @@ struct GameProfile { // What the menu says about them, after "Standard: L and R, like the // shoulders." const char *trigger_profiles_note = nullptr; + // Tilt controls from motion sensors. Null: the game does not tilt, and + // the menu offers none. + const TiltControls *tilt = nullptr; // --- Hooks ------------------------------------------------------------- // Called after the framework has registered its own HLE, so a game can add diff --git a/host/settings/settings.cpp b/host/settings/settings.cpp index 8f825b6..c4d392c 100644 --- a/host/settings/settings.cpp +++ b/host/settings/settings.cpp @@ -109,6 +109,9 @@ const Names kPerfDisplays{{{PerfDisplay::Off, "off"}, {PerfDisplay::Overlay, "overlay"}, {PerfDisplay::OverlayAndLog, "overlay+log"}, {PerfDisplay::Log, "log"}}}; +const Names kTiltModes{{{input::tilt::Mode::Angle, "angle"}, + {input::tilt::Mode::AngleLevel, "level"}, + {input::tilt::Mode::Rate, "rate"}}}; const Names kRightSticks{ {{RightStick::Camera, "camera"}, {RightStick::DPad, "dpad"}, {RightStick::Off, "off"}}}; // A trigger profile by the key the game gives it, or "standard" for L and R. @@ -304,6 +307,38 @@ const std::vector &fields() { [](const Settings &s) { return format_float(s.touch_camera_speed); }, nullptr}, BOOL_FIELD("input.invert_mouse_x", invert_mouse_x), BOOL_FIELD("input.invert_mouse_y", invert_mouse_y), + {"input.tilt", "TILT", + [](Settings &s, const std::string &t) { return parse_bool(t, s.tilt); }, + [](const Settings &s) { return std::string(s.tilt ? "1" : "0"); }, + [](Settings &s, const char *t) { s.tilt = variable_flag(t); }}, + {"input.tilt_mode", "TILT_MODE", + [](Settings &s, const std::string &t) { return kTiltModes.parse(t, s.tilt_mode); }, + [](const Settings &s) { return kTiltModes.format(s.tilt_mode); }, + [](Settings &s, const char *t) { + if (!kTiltModes.parse(t, s.tilt_mode)) std::cerr << "[settings] " << env_name("TILT_MODE") << ": angle, level or rate\n"; + }}, + {"input.tilt_full", "TILT_FULL", + [](Settings &s, const std::string &t) { + return parse_float(t, input::tilt::kMinFullTilt, input::tilt::kMaxFullTilt, s.tilt_full); + }, + [](const Settings &s) { return format_float(s.tilt_full); }, + [](Settings &s, const char *t) { + s.tilt_full = variable_float(t, 12.0f, input::tilt::kMinFullTilt, input::tilt::kMaxFullTilt); + }}, + {"input.tilt_dead_zone", "TILT_DEAD_ZONE", + [](Settings &s, const std::string &t) { + return parse_float(t, input::tilt::kMinDeadZone, input::tilt::kMaxDeadZone, s.tilt_dead_zone); + }, + [](const Settings &s) { return format_float(s.tilt_dead_zone); }, + [](Settings &s, const char *t) { + s.tilt_dead_zone = variable_float(t, 4.0f, input::tilt::kMinDeadZone, input::tilt::kMaxDeadZone); + }}, + BOOL_FIELD("input.tilt_invert", tilt_invert), + {"input.tilt_level_limit", nullptr, + [](Settings &s, const std::string &t) { + return parse_float(t, input::tilt::kMinLevelLimit, input::tilt::kMaxLevelLimit, s.tilt_level_limit); + }, + [](const Settings &s) { return format_float(s.tilt_level_limit); }, nullptr}, {"input.name_entry", "OSK_MODE", [](Settings &s, const std::string &t) { return kNameEntries.parse(t, s.name_entry); }, [](const Settings &s) { return kNameEntries.format(s.name_entry); }, diff --git a/host/settings/settings.hpp b/host/settings/settings.hpp index dc2d92b..707affb 100644 --- a/host/settings/settings.hpp +++ b/host/settings/settings.hpp @@ -1,6 +1,7 @@ #pragma once #include "input/bindings.hpp" +#include "input/tilt.hpp" #include #include @@ -97,6 +98,15 @@ struct Settings { float touch_opacity{0.5f}; // 0.1-1 float touch_size{1.0f}; // 0.6-1.6 of the default size float touch_camera_speed{180.0f}; // degrees the camera turns for a drag across the screen's height + // Tilt controls, for a game that tilts (GameProfile::tilt): the roll of + // a gamepad with motion sensors, or of the phone itself, presses the + // game's tilt buttons (input/tilt.hpp). Off until the player turns it on. + bool tilt{}; + input::tilt::Mode tilt_mode{input::tilt::Mode::Angle}; + float tilt_full{12.0f}; // degrees of roll from neutral for a full tilt + float tilt_dead_zone{4.0f}; // degrees of roll from neutral before anything is pressed + bool tilt_invert{}; + float tilt_level_limit{10.0f}; // Angle + level horizon: degrees the picture turns at most NameEntry name_entry{NameEntry::Keyboard}; // on-screen keyboard, or the name below at once std::string name{game_default_name()}; // the fixed name diff --git a/host/ui/input_script.cpp b/host/ui/input_script.cpp index b43d0c4..5eab793 100644 --- a/host/ui/input_script.cpp +++ b/host/ui/input_script.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -43,6 +44,11 @@ struct State { std::uint8_t mouse_button{}; }; std::vector releases; + // The virtual pad's motion sensors (tilt, gyro), sent again every frame + // as a real pad streams them. + bool sensors{}; + float accel[3]{0.0f, 9.80665f, 0.0f}; + float gyro[3]{}; // Strings handed to SDL events must outlive them. std::deque strings; // _INPUT_LIVE: a file whose appended lines are read as they come. @@ -98,6 +104,8 @@ std::pair name_and_frames(const std::string &argumen bool mouse_step(const std::string &action) { return action == "mouse" || action == "click"; } +bool sensor_step(const std::string &action) { return action == "tilt" || action == "gyro"; } + void run(const Step &step) { State &s = state(); std::cout << "[script] frame " << s.frame << ": " << step.action << " " << step.argument << std::endl; @@ -162,6 +170,27 @@ void run(const Step &step) { return; } SDL_SetJoystickVirtualAxis(s.pad, axis, static_cast(std::clamp(value, -1.0f, 1.0f) * 32767.0f)); + } else if (step.action == "tilt") { + if (s.pad == nullptr) return; + float roll = 0.0f; + float pitch = 0.0f; + std::stringstream(step.argument) >> roll >> pitch; + constexpr float kRadians = 3.14159265358979f / 180.0f; + constexpr float kGravity = 9.80665f; + // What a pad at rest reads: the reaction to gravity, straight up. + s.accel[0] = -std::sin(roll * kRadians) * kGravity; + s.accel[1] = std::cos(roll * kRadians) * std::cos(pitch * kRadians) * kGravity; + s.accel[2] = std::cos(roll * kRadians) * std::sin(pitch * kRadians) * kGravity; + } else if (step.action == "gyro") { + if (s.pad == nullptr) return; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + std::stringstream(step.argument) >> x >> y >> z; + constexpr float kRadians = 3.14159265358979f / 180.0f; + s.gyro[0] = x * kRadians; + s.gyro[1] = y * kRadians; + s.gyro[2] = z * kRadians; } else if (step.action == "text") { SDL_Event event{}; event.type = SDL_EVENT_TEXT_INPUT; @@ -247,6 +276,7 @@ void attach() { if (file) s.live_offset = file.tellg(); uses_pad = true; uses_mouse = true; + s.sensors = true; std::cout << "[script] reading live input from " << s.live_path << std::endl; } if (const char *text = portablekit::env("INPUT_SCRIPT"); text != nullptr) { @@ -255,7 +285,8 @@ void attach() { while (std::getline(list, item, ';')) { Step step; if (!parse_step(item, 0u, step)) continue; - uses_pad = uses_pad || step.action == "pad" || step.action == "axis"; + uses_pad = uses_pad || step.action == "pad" || step.action == "axis" || sensor_step(step.action); + s.sensors = s.sensors || sensor_step(step.action); uses_mouse = uses_mouse || mouse_step(step.action); s.steps.push_back(step); } @@ -281,6 +312,13 @@ void attach_pad(State &s) { desc.button_mask = (1u << SDL_GAMEPAD_BUTTON_COUNT) - 1u; // The renderer gives the game this pad over a real one by its name. desc.name = kInputScriptName; + // A gyroscope and an accelerometer only for a script that moves them, so + // every other script's pad stays the plain one it always was. + static const SDL_VirtualJoystickSensorDesc kSensors[] = {{SDL_SENSOR_ACCEL, 60.0f}, {SDL_SENSOR_GYRO, 60.0f}}; + if (s.sensors) { + desc.nsensors = 2u; + desc.sensors = kSensors; + } const SDL_JoystickID id = SDL_AttachVirtualJoystick(&desc); if (id == 0) { std::cout << "[script] cannot attach a virtual gamepad: " << SDL_GetError() << std::endl; @@ -293,7 +331,7 @@ void attach_pad(State &s) { void tick() { State &s = state(); - if (s.steps.empty() && s.releases.empty() && s.live_path.empty()) return; + if (s.steps.empty() && s.releases.empty() && s.live_path.empty() && !s.sensors) return; ++s.frame; if (!s.live_path.empty() && s.frame % 10u == 0u) read_live(s); for (auto it = s.releases.begin(); it != s.releases.end();) { @@ -310,6 +348,11 @@ void tick() { run(s.steps.front()); s.steps.pop_front(); } + if (s.sensors && s.pad != nullptr) { + const Uint64 now = SDL_GetTicksNS(); + SDL_SendJoystickVirtualSensorData(s.pad, SDL_SENSOR_ACCEL, now, s.accel, 3); + SDL_SendJoystickVirtualSensorData(s.pad, SDL_SENSOR_GYRO, now, s.gyro, 3); + } } } // namespace portablekit::ui::script diff --git a/host/ui/input_script.hpp b/host/ui/input_script.hpp index 2d00338..b7be24d 100644 --- a/host/ui/input_script.hpp +++ b/host/ui/input_script.hpp @@ -18,6 +18,13 @@ // leftstick, rightstick, leftshoulder, dpup, dpdown, ...) // axis NAME VALUE hold an axis of the virtual gamepad at VALUE, -1 to 1 // (leftx, lefty, rightx, righty, lefttrigger, righttrigger) +// tilt ROLL [PITCH] hold the virtual gamepad's accelerometer where it reads +// with the pad rolled ROLL degrees (right side down +// positive) and pitched PITCH degrees back from flat, 0 by +// default: tilt controls without a pad that has sensors +// gyro X Y Z hold its gyroscope at X, Y, Z degrees a second about the +// screen's axes (right, up, towards the player); a turn +// clockwise as the player sees it is a negative Z // text STRING type text // drop PATH drop a file onto the window // shot NAME write the window image to _SCREENSHOT_DIR/NAME.bmp diff --git a/host/ui/menu.cpp b/host/ui/menu.cpp index b71f547..d5b4426 100644 --- a/host/ui/menu.cpp +++ b/host/ui/menu.cpp @@ -22,6 +22,7 @@ #include "gpu/vulkan_renderer.hpp" #include "profile.hpp" #include "input/bindings.hpp" +#include "input/motion.hpp" #include "install/installer.hpp" #include "install/user_data.hpp" #include "perf/frame_stats.hpp" @@ -119,6 +120,7 @@ class Menu { void video(); void audio(); void controls(); + void tilt_controls(); void network(); void system(); bool confirm_dialog(); @@ -577,6 +579,102 @@ bool address_character(char32_t c) { c == U':' || c == U'-' || c == U'[' || c == U']'; } +// Only for a game that tilts (GameProfile::tilt). +void Menu::tilt_controls() { + const TiltControls *tilt = portablekit::game().tilt; + if (tilt == nullptr) return; + settings::Settings &s = settings::current(); + section("Tilt controls"); + { + std::string description = "Tilt the gamepad, or the phone itself, to tilt the game: its motion sensors press " + "the tilt buttons, and the buttons keep working. It needs a gamepad with a " + "gyroscope (DualShock 4, DualSense, Switch Pro, a Steam Deck with Steam Input off " + "for this game) or a phone."; + if (tilt->note != nullptr) description += std::string(" ") + tilt->note; + if (toggle_row("Tilt with motion", s.tilt, options_for("input.tilt", description))) { + s.tilt = !s.tilt; + settings::save(); + } + } + const auto off = [&](RowOptions options) { + if (!s.tilt && !options.disabled) { + options.disabled = true; + options.note = "Tilt with motion is off"; + } + return options; + }; + { + static const char *const kModes[] = {"Angle", "Angle + level horizon", "Rate"}; + const int current = static_cast(s.tilt_mode); + if (const int delta = choice_row( + "Gyro mode", kModes[current], + off(options_for("input.tilt_mode", + "Angle: how far the device is tilted from where it was re-centred tilts the game; " + "the picture stays as it is. Angle + level horizon (experimental): the same, and " + "the picture is turned against the device and zoomed a little so the game's " + "horizon stays level with the real one; turning pictures can cause motion " + "sickness. Rate: turning the device tilts, and the tilt stays when the turning " + "stops, so the device can go back to a comfortable hold.")))) { + s.tilt_mode = static_cast(cycle(current, delta, 3)); + settings::save(); + } + } + int full = static_cast(std::lround(s.tilt_full)); + if (slider_row("Full tilt at", full, static_cast(input::tilt::kMinFullTilt), + static_cast(input::tilt::kMaxFullTilt), 1, "%d deg", + off(options_for("input.tilt_full", "Sensitivity: how far the device is tilted for the game's " + "full tilt. Less is more sensitive.")))) { + s.tilt_full = static_cast(full); + s.tilt_dead_zone = std::min(s.tilt_dead_zone, s.tilt_full - 1.0f); + settings::save(); + } + int dead = static_cast(std::lround(s.tilt_dead_zone)); + if (slider_row("Tilt dead zone", dead, static_cast(input::tilt::kMinDeadZone), + static_cast(input::tilt::kMaxDeadZone), 1, "%d deg", + off(options_for("input.tilt_dead_zone", "How far the device is tilted before anything happens, " + "so a steady hand does not tilt the game.")))) { + s.tilt_dead_zone = static_cast(dead); + settings::save(); + } + if (toggle_row("Invert tilt", s.tilt_invert, + off(options_for("input.tilt_invert", "Tilting the device left tilts the game right.")))) { + s.tilt_invert = !s.tilt_invert; + settings::save(); + } + { + RowOptions o = off(options_for("input.tilt_level_limit", + "How far the picture may turn to keep the horizon level. The picture is " + "zoomed as it turns so no corner is left empty; a larger limit crops more.")); + if (s.tilt_mode != input::tilt::Mode::AngleLevel && !o.disabled) { + o.disabled = true; + o.note = "Only for Angle + level horizon"; + } + int limit = static_cast(std::lround(s.tilt_level_limit)); + if (slider_row("Level horizon limit", limit, static_cast(input::tilt::kMinLevelLimit), + static_cast(input::tilt::kMaxLevelLimit), 1, "%d deg", o)) { + s.tilt_level_limit = static_cast(limit); + settings::save(); + } + } + if (button_row("Re-centre tilt", + off({false, {}, "Makes however the device is held now the level position. This also happens " + "when the game starts, when this menu closes, and with R3 on a gamepad."}))) { + input::motion::recenter(); + } + if (s.tilt) { + // Read here too: the game's input is off while the menu is open, and + // the row below is how a player sees the sensors work. + (void)input::motion::sample(renderer().gamepad(), renderer().window(), false); + info_row("Motion from", input::motion::source()); + float roll = 0.0f; + if (input::motion::reading(roll)) { + char text[32]; + std::snprintf(text, sizeof(text), "%+.1f deg", static_cast(roll)); + info_row("Tilted now", text); + } + } +} + void Menu::controls() { settings::Settings &s = settings::current(); section("Gamepad"); @@ -709,6 +807,8 @@ void Menu::controls() { } } + tilt_controls(); + section(game().player_name_label); { const bool keyboard = s.name_entry == settings::NameEntry::Keyboard; @@ -826,6 +926,12 @@ void Menu::controls() { restore("input.touch_opacity", s.touch_opacity, d.touch_opacity); restore("input.touch_size", s.touch_size, d.touch_size); restore("input.touch_camera_speed", s.touch_camera_speed, d.touch_camera_speed); + restore("input.tilt", s.tilt, d.tilt); + restore("input.tilt_mode", s.tilt_mode, d.tilt_mode); + restore("input.tilt_full", s.tilt_full, d.tilt_full); + restore("input.tilt_dead_zone", s.tilt_dead_zone, d.tilt_dead_zone); + restore("input.tilt_invert", s.tilt_invert, d.tilt_invert); + restore("input.tilt_level_limit", s.tilt_level_limit, d.tilt_level_limit); s.bindings = d.bindings; settings::save(); } diff --git a/tests/tilt_tests.cpp b/tests/tilt_tests.cpp new file mode 100644 index 0000000..60b2cc8 --- /dev/null +++ b/tests/tilt_tests.cpp @@ -0,0 +1,221 @@ +// Tilt controls (input/tilt.hpp): the device's roll as tilt buttons, without +// SDL, sensors or game data. +#include "input/tilt.hpp" + +#include +#include + +namespace { +using namespace portablekit::input::tilt; +int failures{}; + +void check(bool condition, const char *message) { + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} + +constexpr float kPi = 3.14159265358979f; +constexpr float kG = 9.80665f; +constexpr float kFrame = 1.0f / 60.0f; + +// What the accelerometer reads with the device rolled `roll` degrees (right +// side down) and pitched `pitch` degrees back from flat towards the player. +Reading accel_at(float roll, float pitch) { + const float r = roll * kPi / 180.0f; + const float p = pitch * kPi / 180.0f; + Reading reading; + reading.has_accel = true; + // "Up" in the device's axes: the X axis dips by the roll, and what is + // left of up is shared between Y and Z by the pitch. + reading.accel[0] = -std::sin(r) * kG; + reading.accel[1] = std::cos(r) * std::cos(p) * kG; + reading.accel[2] = std::cos(r) * std::sin(p) * kG; + return reading; +} + +void test_roll_from_accelerometer() { + for (const float pitch : {0.0f, 30.0f, 60.0f, 85.0f}) { + RollEstimator estimator; + estimator.update(accel_at(10.0f, pitch), kFrame); + check(estimator.valid(), "one accelerometer reading is enough"); + check(std::fabs(estimator.roll() - 10.0f) < 0.01f, "roll does not depend on the pitch"); + } + RollEstimator estimator; + estimator.update(accel_at(-8.0f, 45.0f), kFrame); + check(estimator.roll() < -7.9f && estimator.roll() > -8.1f, "left side down is negative"); + RollEstimator falling; + Reading nothing; + nothing.has_accel = true; + falling.update(nothing, kFrame); + check(!falling.valid(), "a zero reading says nothing"); +} + +void test_roll_follows_accelerometer() { + RollEstimator estimator; + estimator.update(accel_at(0.0f, 40.0f), kFrame); + for (int i = 0; i < 60; ++i) estimator.update(accel_at(12.0f, 40.0f), kFrame); + check(std::fabs(estimator.roll() - 12.0f) < 0.5f, "a steady roll is reached within a second"); + check(estimator.rate() > -1.0f && estimator.rate() < 1.0f, "and its rate settles"); +} + +void test_gyro_rate() { + // Flat gamepad (up is +Y): turning clockwise as the player sees it is a + // negative rate about +Z and rolls the right side down. + RollEstimator estimator; + Reading reading = accel_at(0.0f, 0.0f); + estimator.update(reading, kFrame); + reading.has_gyro = true; + reading.has_accel = false; + reading.gyro[2] = -30.0f * kPi / 180.0f; + for (int i = 0; i < 30; ++i) estimator.update(reading, kFrame); + check(std::fabs(estimator.rate() - 30.0f) < 0.1f, "clockwise about the player's view is positive roll"); + check(std::fabs(estimator.roll() - 15.0f) < 0.5f, "half a second at 30 deg/s rolls 15 degrees"); + + // Held upright (up is +Z-ish, a phone or a Deck at 70 degrees): the same + // turn about the screen's normal is still roll. + RollEstimator upright; + Reading tilted = accel_at(0.0f, 70.0f); + upright.update(tilted, kFrame); + const float p = 70.0f * kPi / 180.0f; + // A turn about the axis towards the player, which is pitched with the + // device: its components in the device's axes. + tilted.has_gyro = true; + tilted.has_accel = false; + tilted.gyro[1] = 0.0f; + tilted.gyro[2] = -30.0f * kPi / 180.0f; + upright.update(tilted, kFrame); + check(upright.rate() > 30.0f * std::cos(p) * 0.99f, "a turn about the screen's normal rolls when upright"); +} + +void test_fusion_rejects_shake() { + // A shake that the gyroscope says is not a turn barely moves the roll. + RollEstimator estimator; + Reading still = accel_at(0.0f, 30.0f); + still.has_gyro = true; + estimator.update(still, kFrame); + Reading shaken = still; + shaken.accel[0] = -0.5f * kG; // a sideways jolt: 30 degrees if taken as roll + estimator.update(shaken, kFrame); + check(std::fabs(estimator.roll()) < 3.0f, "one jolt moves the fused roll only a little"); +} + +void test_dead_zone_and_hysteresis() { + Mapper mapper; + const Tuning tuning{Mode::Angle, 12.0f, 4.0f, false}; + check(mapper.update(0.0f, 0.0f, kFrame, tuning).direction == 0, "neutral presses nothing"); + check(mapper.update(3.9f, 0.0f, kFrame, tuning).direction == 0, "inside the dead zone presses nothing"); + Output out = mapper.update(4.5f, 0.0f, kFrame, tuning); + check(out.direction == 1, "past the dead zone to the right presses right"); + check(out.strength > 0.0f && out.strength < 0.1f, "just past it is a weak tilt"); + check(mapper.update(3.0f, 0.0f, kFrame, tuning).direction == 1, "a little back inside still holds"); + check(mapper.update(2.4f, 0.0f, kFrame, tuning).direction == 0, "past the hysteresis it lets go"); + check(mapper.update(3.9f, 0.0f, kFrame, tuning).direction == 0, "and needs the dead zone again"); + out = mapper.update(-20.0f, 0.0f, kFrame, tuning); + check(out.direction == -1 && out.strength == 1.0f, "far to the left is full left"); + check(mapper.update(5.0f, 0.0f, kFrame, tuning).direction == 1, "crossing over changes side at once"); + // A dead zone smaller than the hysteresis still lets go. + Mapper small; + const Tuning narrow{Mode::Angle, 12.0f, 1.0f, false}; + check(small.update(0.0f, 0.0f, kFrame, narrow).direction == 0, "neutral"); + check(small.update(1.2f, 0.0f, kFrame, narrow).direction == 1, "a narrow dead zone presses"); + check(small.update(0.2f, 0.0f, kFrame, narrow).direction == 0, "and lets go near neutral"); +} + +void test_recenter_and_invert() { + Mapper mapper; + Tuning tuning{Mode::Angle, 12.0f, 4.0f, false}; + // The first roll seen is neutral: a player holding the device at 20 + // degrees presses nothing. + check(mapper.update(20.0f, 0.0f, kFrame, tuning).direction == 0, "the first roll is neutral"); + check(std::fabs(mapper.neutral() - 20.0f) < 0.001f, "and remembered"); + check(mapper.update(26.0f, 0.0f, kFrame, tuning).direction == 1, "rolls count from it"); + mapper.recenter(); + check(mapper.update(26.0f, 0.0f, kFrame, tuning).direction == 0, "re-centring makes the hold neutral"); + tuning.invert = true; + check(mapper.update(31.0f, 0.0f, kFrame, tuning).direction == -1, "inverted, right presses left"); +} + +void test_level_horizon() { + Mapper mapper; + const Tuning level{Mode::AngleLevel, 12.0f, 4.0f, false}; + (void)mapper.update(10.0f, 0.0f, kFrame, level); + Output out = mapper.update(17.0f, 0.0f, kFrame, level); + check(std::fabs(out.level_degrees - 7.0f) < 0.001f, "the picture turns by the roll from neutral"); + check(out.direction == 1, "and the tilt still presses"); + out = mapper.update(80.0f, 0.0f, kFrame, level); + check(out.level_degrees == 10.0f, "never further than the limit, 10 degrees by default"); + Tuning wider = level; + wider.level_limit = 15.0f; + check(mapper.update(80.0f, 0.0f, kFrame, wider).level_degrees == 15.0f, "the limit is tunable"); + wider.level_limit = 90.0f; + check(mapper.update(80.0f, 0.0f, kFrame, wider).level_degrees == kMaxLevelLimit, "within its own bounds"); + Mapper plain; + const Tuning angle{Mode::Angle, 12.0f, 4.0f, false}; + (void)plain.update(0.0f, 0.0f, kFrame, angle); + check(plain.update(10.0f, 0.0f, kFrame, angle).level_degrees == 0.0f, "Angle alone leaves the picture"); +} + +void test_rate_mode() { + Mapper mapper; + const Tuning rate{Mode::Rate, 12.0f, 4.0f, false}; + (void)mapper.update(0.0f, 0.0f, kFrame, rate); + // A quick turn: 0.25 s at 80 deg/s. + Output out; + for (int i = 0; i < 15; ++i) out = mapper.update(0.0f, 80.0f, kFrame, rate); + check(out.direction == 1, "a quick turn to the right tilts right"); + // Bringing the device back slowly adds nothing. + for (int i = 0; i < 60; ++i) out = mapper.update(0.0f, -15.0f, kFrame, rate); + check(out.direction == 1, "a slow return keeps the tilt"); + // Held still, it fades. + for (int i = 0; i < 60 * 8; ++i) out = mapper.update(0.0f, 0.0f, kFrame, rate); + check(out.direction == 0, "held still, the tilt fades"); + // A quick turn the other way tilts left at once. + for (int i = 0; i < 15; ++i) out = mapper.update(0.0f, -80.0f, kFrame, rate); + check(out.direction == -1, "a quick turn to the left tilts left"); + mapper.recenter(); + check(mapper.update(0.0f, 0.0f, kFrame, rate).direction == 0, "re-centring drops the tilt"); + // Slow drift alone never tilts. + Mapper drift; + for (int i = 0; i < 600; ++i) out = drift.update(0.0f, 19.0f, kFrame, rate); + check(out.direction == 0, "turning slower than the threshold never tilts"); +} + +void test_pulse() { + Pulse pulse; + int down = 0; + for (int i = 0; i < 100; ++i) down += pulse.step(1, 1.0f, true) ? 1 : 0; + check(down == 100, "full strength holds the button"); + Pulse half; + down = 0; + for (int i = 0; i < 100; ++i) down += half.step(1, 0.5f, true) ? 1 : 0; + check(down >= 58 && down <= 62, "half strength presses about 60 frames in 100"); + Pulse fresh; + check(fresh.step(-1, 0.0f, true), "a new press starts down"); + check(!fresh.step(0, 0.0f, true), "no direction presses nothing"); + Pulse digital; + down = 0; + for (int i = 0; i < 100; ++i) down += digital.step(1, 0.1f, false) ? 1 : 0; + check(down == 100, "not proportional, any tilt holds the button"); +} + +} // namespace + +int main() { + test_roll_from_accelerometer(); + test_roll_follows_accelerometer(); + test_gyro_rate(); + test_fusion_rejects_shake(); + test_dead_zone_and_hysteresis(); + test_recenter_and_invert(); + test_level_horizon(); + test_rate_mode(); + test_pulse(); + if (failures != 0) { + std::cerr << failures << " tilt test(s) failed\n"; + return 1; + } + std::cout << "tilt tests passed\n"; + return 0; +}