From 3a5fa35449b145be84a7374c20e211370a18dba1 Mon Sep 17 00:00:00 2001 From: Eric Warmenhoven Date: Fri, 6 Mar 2026 15:44:46 -0500 Subject: [PATCH] libretro: draw cursor in vulkan --- src/citra_libretro/citra_libretro.cpp | 1 + .../emu_window/libretro_window.cpp | 12 +- .../emu_window/libretro_window.h | 3 + src/citra_libretro/input/mouse_tracker.cpp | 53 ++--- src/citra_libretro/input/mouse_tracker.h | 13 +- src/core/frontend/emu_window.h | 17 ++ src/video_core/host_shaders/CMakeLists.txt | 2 + .../host_shaders/vulkan_cursor.frag | 12 ++ .../host_shaders/vulkan_cursor.vert | 12 ++ .../renderer_vulkan/renderer_vulkan.cpp | 197 ++++++++++++++++++ .../renderer_vulkan/renderer_vulkan.h | 7 + 11 files changed, 285 insertions(+), 44 deletions(-) create mode 100644 src/video_core/host_shaders/vulkan_cursor.frag create mode 100644 src/video_core/host_shaders/vulkan_cursor.vert diff --git a/src/citra_libretro/citra_libretro.cpp b/src/citra_libretro/citra_libretro.cpp index 328cb8e89..9263fc46b 100644 --- a/src/citra_libretro/citra_libretro.cpp +++ b/src/citra_libretro/citra_libretro.cpp @@ -626,6 +626,7 @@ bool retro_load_game(const struct retro_game_info* info) { #endif break; case Settings::GraphicsAPI::Software: + emu_instance->emu_window->CreateContext(); emu_instance->game_loaded = do_load_game(); if (!emu_instance->game_loaded) return false; diff --git a/src/citra_libretro/emu_window/libretro_window.cpp b/src/citra_libretro/emu_window/libretro_window.cpp index 10d49d883..a1bd863a4 100644 --- a/src/citra_libretro/emu_window/libretro_window.cpp +++ b/src/citra_libretro/emu_window/libretro_window.cpp @@ -9,6 +9,7 @@ #include "audio_core/audio_types.h" #include "citra_libretro/citra_libretro.h" +#include "citra_libretro/core_settings.h" #include "citra_libretro/environment.h" #include "citra_libretro/input/input_factory.h" #include "common/settings.h" @@ -85,9 +86,7 @@ void EmuWindow_LibRetro::SwapBuffers() { } case Settings::GraphicsAPI::Vulkan: { #ifdef ENABLE_VULKAN - if (enableEmulatedPointer && tracker) { - tracker->Render(width, height); - } + // Cursor is drawn inside the Vulkan render pass (RendererVulkan::DrawCursor) LibRetro::UploadVideoFrame(RETRO_HW_FRAME_BUFFER_VALID, static_cast(width), static_cast(height), 0); #endif @@ -340,3 +339,10 @@ void EmuWindow_LibRetro::CreateContext() { void EmuWindow_LibRetro::DestroyContext() { tracker = nullptr; } + +Frontend::EmuWindow::CursorInfo EmuWindow_LibRetro::GetCursorInfo() const { + if (enableEmulatedPointer && tracker && LibRetro::settings.render_touchscreen) { + return tracker->GetCursorInfo(); + } + return {}; +} diff --git a/src/citra_libretro/emu_window/libretro_window.h b/src/citra_libretro/emu_window/libretro_window.h index 235d14319..fb13e205a 100644 --- a/src/citra_libretro/emu_window/libretro_window.h +++ b/src/citra_libretro/emu_window/libretro_window.h @@ -57,6 +57,9 @@ public: /// When true, SwapBuffers() is suppressed (used during savestate drain loops) bool suppressPresentation = false; + /// Get cursor state for rendering a touch crosshair on the bottom screen. + CursorInfo GetCursorInfo() const override; + private: /// Called when a configuration change affects the minimal size of the window void OnMinimalClientAreaChangeRequest(std::pair minimal_size) override; diff --git a/src/citra_libretro/input/mouse_tracker.cpp b/src/citra_libretro/input/mouse_tracker.cpp index 37e167b2c..d553ef2f4 100644 --- a/src/citra_libretro/input/mouse_tracker.cpp +++ b/src/citra_libretro/input/mouse_tracker.cpp @@ -219,17 +219,9 @@ void MouseTracker::Update(int bufferWidth, int bufferHeight, Restrict(0, 0, layout.bottom_screen.GetWidth(), layout.bottom_screen.GetHeight()); - // Make the coordinates 0 -> 1 - projectedX = (float)x / layout.bottom_screen.GetWidth(); - projectedY = (float)y / layout.bottom_screen.GetHeight(); - - // Ensure that the projected position doesn't overlap outside the bottom screen framebuffer. - // TODO: Provide config option - renderRatio = (float)layout.bottom_screen.GetHeight() / 30; - - // Map the mouse coord to the bottom screen's position - projectedX = layout.bottom_screen.left + projectedX * layout.bottom_screen.GetWidth(); - projectedY = layout.bottom_screen.top + projectedY * layout.bottom_screen.GetHeight(); + // Store as bottom-screen-local pixel coordinates + projectedX = static_cast(x); + projectedY = static_cast(y); isPressed = state; @@ -241,10 +233,15 @@ void MouseTracker::Render(int bufferWidth, int bufferHeight, void* framebuffer_d return; } - // Delegate to renderer-specific implementation + // Delegate to renderer-specific implementation. + // Convert from bottom-screen-local to layout-absolute for the legacy renderers. if (cursor_renderer) { - cursor_renderer->Render(bufferWidth, bufferHeight, projectedX, projectedY, renderRatio, - framebuffer_layout, framebuffer_data); + const float abs_x = framebuffer_layout.bottom_screen.left + projectedX; + const float abs_y = framebuffer_layout.bottom_screen.top + projectedY; + const float ratio = + static_cast(framebuffer_layout.bottom_screen.GetHeight()) / 30.0f; + cursor_renderer->Render(bufferWidth, bufferHeight, abs_x, abs_y, ratio, framebuffer_layout, + framebuffer_data); } } @@ -349,30 +346,12 @@ void OpenGLCursorRenderer::Render(int bufferWidth, int bufferHeight, float proje #endif #ifdef ENABLE_VULKAN -// Vulkan-specific cursor renderer implementation -VulkanCursorRenderer::VulkanCursorRenderer() { - // Vulkan cursor rendering will be integrated into the main rendering pipeline -} - +// Vulkan cursor is drawn by RendererVulkan::DrawCursor() inside the render pass. +// This class exists only to satisfy the CursorRenderer interface. +VulkanCursorRenderer::VulkanCursorRenderer() = default; VulkanCursorRenderer::~VulkanCursorRenderer() = default; - -void VulkanCursorRenderer::Render(int bufferWidth, int bufferHeight, float projectedX, - float projectedY, float renderRatio, - const Layout::FramebufferLayout& layout, void* framebuffer_data) { - // Use shared coordinate calculation - CursorCoordinates coords(bufferWidth, bufferHeight, projectedX, projectedY, renderRatio, - layout); - - // TODO: Implement actual Vulkan cursor drawing using the renderer's command buffer - // This would involve: - // 1. Creating a simple vertex buffer with cursor geometry using coords - // 2. Using a basic shader pipeline - // 3. Recording draw commands into the current command buffer - // 4. Using blend mode similar to OpenGL (ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR) - - // For now, this is a placeholder - the cursor won't be visible in Vulkan mode - // but the touchscreen input will still work -} +void VulkanCursorRenderer::Render(int, int, float, float, float, const Layout::FramebufferLayout&, + void*) {} #endif // Software-specific cursor renderer implementation diff --git a/src/citra_libretro/input/mouse_tracker.h b/src/citra_libretro/input/mouse_tracker.h index 3f4435792..9f0e7e179 100644 --- a/src/citra_libretro/input/mouse_tracker.h +++ b/src/citra_libretro/input/mouse_tracker.h @@ -5,6 +5,7 @@ #pragma once #include "common/math_util.h" +#include "core/frontend/emu_window.h" #include "core/frontend/framebuffer_layout.h" #ifdef ENABLE_OPENGL @@ -47,10 +48,15 @@ public: return isPressed; } - /// Get the pressed position, relative to the framebuffer. + /// Get the pressed position in layout-absolute coordinates. std::pair GetPressedPosition() { - return {static_cast(projectedX), - static_cast(projectedY)}; + return {static_cast(framebuffer_layout.bottom_screen.left + projectedX), + static_cast(framebuffer_layout.bottom_screen.top + projectedY)}; + } + + /// Get cursor rendering state for external renderers (e.g. Vulkan). + Frontend::EmuWindow::CursorInfo GetCursorInfo() const { + return {true, projectedX, projectedY}; } private: @@ -62,7 +68,6 @@ private: float projectedX; float projectedY; - float renderRatio; bool isPressed; diff --git a/src/core/frontend/emu_window.h b/src/core/frontend/emu_window.h index 1f0fec7b7..175f41860 100644 --- a/src/core/frontend/emu_window.h +++ b/src/core/frontend/emu_window.h @@ -269,6 +269,23 @@ public: return true; } + /// Cursor state for rendering a touch crosshair on the bottom screen. + struct CursorInfo { + bool visible = false; + + /// Cursor position in bottom-screen-local pixel coordinates. + /// Origin is the top-left corner of the bottom screen, with x ranging + /// from 0 to bottom_screen.GetWidth() and y from 0 to + /// bottom_screen.GetHeight(). + float projected_x = 0; + float projected_y = 0; + }; + + /// Returns the current cursor state. Override to provide cursor position. + virtual CursorInfo GetCursorInfo() const { + return {}; + } + Settings::StereoRenderOption get3DMode() const; protected: diff --git a/src/video_core/host_shaders/CMakeLists.txt b/src/video_core/host_shaders/CMakeLists.txt index 9a514fecf..964000d92 100644 --- a/src/video_core/host_shaders/CMakeLists.txt +++ b/src/video_core/host_shaders/CMakeLists.txt @@ -24,6 +24,8 @@ set(SHADER_FILES vulkan_present_anaglyph.frag vulkan_present_interlaced.frag vulkan_blit_depth_stencil.frag + vulkan_cursor.frag + vulkan_cursor.vert ) set(SHADER_INCLUDE ${CMAKE_CURRENT_BINARY_DIR}/include) diff --git a/src/video_core/host_shaders/vulkan_cursor.frag b/src/video_core/host_shaders/vulkan_cursor.frag new file mode 100644 index 000000000..ded719e24 --- /dev/null +++ b/src/video_core/host_shaders/vulkan_cursor.frag @@ -0,0 +1,12 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#version 450 core +#extension GL_ARB_separate_shader_objects : enable + +layout (location = 0) out vec4 color; + +void main() { + color = vec4(1.0, 1.0, 1.0, 1.0); +} diff --git a/src/video_core/host_shaders/vulkan_cursor.vert b/src/video_core/host_shaders/vulkan_cursor.vert new file mode 100644 index 000000000..4e51a1ba3 --- /dev/null +++ b/src/video_core/host_shaders/vulkan_cursor.vert @@ -0,0 +1,12 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#version 450 core +#extension GL_ARB_separate_shader_objects : enable + +layout (location = 0) in vec2 vert_position; + +void main() { + gl_Position = vec4(vert_position, 0.0, 1.0); +} diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp index 0b04b0ba7..0a25c2036 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp @@ -20,6 +20,9 @@ #include "video_core/host_shaders/vulkan_present_interlaced_frag.h" #include "video_core/host_shaders/vulkan_present_vert.h" +#include "video_core/host_shaders/vulkan_cursor_frag.h" +#include "video_core/host_shaders/vulkan_cursor_vert.h" + #include #if defined(__APPLE__) && !defined(HAVE_LIBRETRO) @@ -153,6 +156,10 @@ RendererVulkan::~RendererVulkan() { device.destroyImageView(info.texture.image_view); vmaDestroyImage(instance.GetAllocator(), info.texture.image, info.texture.allocation); } + + device.destroyPipeline(cursor_pipeline); + device.destroyShaderModule(cursor_vertex_shader); + device.destroyShaderModule(cursor_fragment_shader); } void RendererVulkan::PrepareRendertarget() { @@ -294,6 +301,11 @@ void RendererVulkan::CompileShaders() { present_shaders[2] = Compile(HostShaders::VULKAN_PRESENT_INTERLACED_FRAG, vk::ShaderStageFlagBits::eFragment, device, preamble); + cursor_vertex_shader = + Compile(HostShaders::VULKAN_CURSOR_VERT, vk::ShaderStageFlagBits::eVertex, device); + cursor_fragment_shader = + Compile(HostShaders::VULKAN_CURSOR_FRAG, vk::ShaderStageFlagBits::eFragment, device); + auto properties = instance.GetPhysicalDevice().getProperties(); for (std::size_t i = 0; i < present_samplers.size(); i++) { const vk::Filter filter_mode = i == 0 ? vk::Filter::eLinear : vk::Filter::eNearest; @@ -330,6 +342,9 @@ void RendererVulkan::BuildLayouts() { .pPushConstantRanges = &push_range, }; present_pipeline_layout = instance.GetDevice().createPipelineLayoutUnique(layout_info); + + const vk::PipelineLayoutCreateInfo cursor_layout_info = {}; + cursor_pipeline_layout = instance.GetDevice().createPipelineLayoutUnique(cursor_layout_info); } void RendererVulkan::BuildPipelines() { @@ -460,6 +475,126 @@ void RendererVulkan::BuildPipelines() { ASSERT_MSG(result == vk::Result::eSuccess, "Unable to build present pipelines"); present_pipelines[i] = pipeline; } + + // Build cursor pipeline (simple position-only, inverted color blending) + { + const vk::VertexInputBindingDescription cursor_binding = { + .binding = 0, + .stride = sizeof(float) * 2, + .inputRate = vk::VertexInputRate::eVertex, + }; + + const vk::VertexInputAttributeDescription cursor_attribute = { + .location = 0, + .binding = 0, + .format = vk::Format::eR32G32Sfloat, + .offset = 0, + }; + + const vk::PipelineVertexInputStateCreateInfo cursor_vertex_input = { + .vertexBindingDescriptionCount = 1, + .pVertexBindingDescriptions = &cursor_binding, + .vertexAttributeDescriptionCount = 1, + .pVertexAttributeDescriptions = &cursor_attribute, + }; + + const vk::PipelineInputAssemblyStateCreateInfo cursor_input_assembly = { + .topology = vk::PrimitiveTopology::eTriangleList, + .primitiveRestartEnable = false, + }; + + const vk::PipelineRasterizationStateCreateInfo cursor_raster = { + .depthClampEnable = false, + .rasterizerDiscardEnable = false, + .cullMode = vk::CullModeFlagBits::eNone, + .frontFace = vk::FrontFace::eClockwise, + .depthBiasEnable = false, + .lineWidth = 1.0f, + }; + + const vk::PipelineMultisampleStateCreateInfo cursor_multisample = { + .rasterizationSamples = vk::SampleCountFlagBits::e1, + .sampleShadingEnable = false, + }; + + const vk::PipelineColorBlendAttachmentState cursor_blend_attachment = { + .blendEnable = true, + .srcColorBlendFactor = vk::BlendFactor::eOneMinusDstColor, + .dstColorBlendFactor = vk::BlendFactor::eOneMinusSrcColor, + .colorBlendOp = vk::BlendOp::eAdd, + .srcAlphaBlendFactor = vk::BlendFactor::eOne, + .dstAlphaBlendFactor = vk::BlendFactor::eZero, + .alphaBlendOp = vk::BlendOp::eAdd, + .colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA, + }; + + const vk::PipelineColorBlendStateCreateInfo cursor_color_blending = { + .logicOpEnable = false, + .attachmentCount = 1, + .pAttachments = &cursor_blend_attachment, + }; + + const vk::Viewport placeholder_vp = {0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f}; + const vk::Rect2D placeholder_sc = {{0, 0}, {1, 1}}; + const vk::PipelineViewportStateCreateInfo cursor_viewport = { + .viewportCount = 1, + .pViewports = &placeholder_vp, + .scissorCount = 1, + .pScissors = &placeholder_sc, + }; + + const std::array cursor_dynamic_states = { + vk::DynamicState::eViewport, + vk::DynamicState::eScissor, + }; + + const vk::PipelineDynamicStateCreateInfo cursor_dynamic = { + .dynamicStateCount = static_cast(cursor_dynamic_states.size()), + .pDynamicStates = cursor_dynamic_states.data(), + }; + + const vk::PipelineDepthStencilStateCreateInfo cursor_depth = { + .depthTestEnable = false, + .depthWriteEnable = false, + .depthCompareOp = vk::CompareOp::eAlways, + .depthBoundsTestEnable = false, + .stencilTestEnable = false, + }; + + const std::array cursor_shader_stages = { + vk::PipelineShaderStageCreateInfo{ + .stage = vk::ShaderStageFlagBits::eVertex, + .module = cursor_vertex_shader, + .pName = "main", + }, + vk::PipelineShaderStageCreateInfo{ + .stage = vk::ShaderStageFlagBits::eFragment, + .module = cursor_fragment_shader, + .pName = "main", + }, + }; + + const vk::GraphicsPipelineCreateInfo cursor_pipeline_info = { + .stageCount = static_cast(cursor_shader_stages.size()), + .pStages = cursor_shader_stages.data(), + .pVertexInputState = &cursor_vertex_input, + .pInputAssemblyState = &cursor_input_assembly, + .pViewportState = &cursor_viewport, + .pRasterizationState = &cursor_raster, + .pMultisampleState = &cursor_multisample, + .pDepthStencilState = &cursor_depth, + .pColorBlendState = &cursor_color_blending, + .pDynamicState = &cursor_dynamic, + .layout = *cursor_pipeline_layout, + .renderPass = main_present_window.Renderpass(), + }; + + const auto [result, pipeline] = + instance.GetDevice().createGraphicsPipeline({}, cursor_pipeline_info); + ASSERT_MSG(result == vk::Result::eSuccess, "Unable to build cursor pipeline"); + cursor_pipeline = pipeline; + } } void RendererVulkan::ConfigureFramebufferTexture(TextureInfo& texture, @@ -909,9 +1044,71 @@ void RendererVulkan::DrawScreens(Frame* frame, const Layout::FramebufferLayout& } } + DrawCursor(layout); + scheduler.Record([](vk::CommandBuffer cmdbuf) { cmdbuf.endRenderPass(); }); } +void RendererVulkan::DrawCursor(const Layout::FramebufferLayout& layout) { + const auto cursor = render_window.GetCursorInfo(); + if (!cursor.visible) { + return; + } + + const float buf_w = static_cast(layout.width); + const float buf_h = static_cast(layout.height); + + // Convert from bottom-screen-local to layout-absolute, then to NDC + const float abs_x = layout.bottom_screen.left + cursor.projected_x; + const float abs_y = layout.bottom_screen.top + cursor.projected_y; + const float cx = (abs_x / buf_w) * 2.0f - 1.0f; + const float cy = (abs_y / buf_h) * 2.0f - 1.0f; + const float ratio = static_cast(layout.bottom_screen.GetHeight()) / 30.0f; + const float rw = ratio / buf_w; + const float rh = ratio / buf_h; + + // Bottom screen bounds in NDC + const float bl = (layout.bottom_screen.left / buf_w) * 2.0f - 1.0f; + const float bt = (layout.bottom_screen.top / buf_h) * 2.0f - 1.0f; + const float br = (layout.bottom_screen.right / buf_w) * 2.0f - 1.0f; + const float bb = (layout.bottom_screen.bottom / buf_h) * 2.0f - 1.0f; + + // Crosshair geometry clamped to bottom screen bounds + const float vl = std::fmax(cx - rw / 5.0f, bl); + const float vr = std::fmin(cx + rw / 5.0f, br); + const float vt = std::fmax(cy - rh, bt); + const float vb = std::fmin(cy + rh, bb); + + const float hl = std::fmax(cx - rw, bl); + const float hr = std::fmin(cx + rw, br); + const float ht = std::fmax(cy - rh / 5.0f, bt); + const float hb = std::fmin(cy + rh / 5.0f, bb); + + // 12 vertices = 4 triangles (2 for vertical bar, 2 for horizontal bar) + // clang-format off + const float vertices[] = { + // Vertical bar + vl, vt, vr, vt, vr, vb, + vl, vt, vr, vb, vl, vb, + // Horizontal bar + hl, ht, hr, ht, hr, hb, + hl, ht, hr, hb, hl, hb, + }; + // clang-format on + + const u64 size = sizeof(vertices); + auto [data, offset, invalidate] = vertex_buffer.Map(size, 16); + std::memcpy(data, vertices, size); + vertex_buffer.Commit(size); + + scheduler.Record([this, offset = offset, pipeline = cursor_pipeline](vk::CommandBuffer cmdbuf) { + cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); + cmdbuf.bindVertexBuffers(0, vertex_buffer.Handle(), {0}); + const u32 first_vertex = static_cast(offset) / (sizeof(float) * 2); + cmdbuf.draw(12, 1, first_vertex, 0); + }); +} + void RendererVulkan::SwapBuffers() { system.perf_stats->StartSwap(); const Layout::FramebufferLayout& layout = render_window.GetFramebufferLayout(); diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.h b/src/video_core/renderer_vulkan/renderer_vulkan.h index b275f3189..14c9bd34f 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.h +++ b/src/video_core/renderer_vulkan/renderer_vulkan.h @@ -112,6 +112,8 @@ private: void ApplySecondLayerOpacity(float alpha); + void DrawCursor(const Layout::FramebufferLayout& layout); + void LoadFBToScreenInfo(const Pica::FramebufferConfig& framebuffer, ScreenInfo& screen_info, bool right_eye); void FillScreen(Common::Vec3 color, const TextureInfo& texture); @@ -144,6 +146,11 @@ private: std::array screen_infos{}; PresentUniformData draw_info{}; vk::ClearColorValue clear_color{}; + + vk::ShaderModule cursor_vertex_shader{}; + vk::ShaderModule cursor_fragment_shader{}; + vk::Pipeline cursor_pipeline{}; + vk::UniquePipelineLayout cursor_pipeline_layout{}; }; } // namespace Vulkan