diff --git a/src/core/fullscreen_ui.cpp b/src/core/fullscreen_ui.cpp
index 2ed6856eb..1c728ec6d 100644
--- a/src/core/fullscreen_ui.cpp
+++ b/src/core/fullscreen_ui.cpp
@@ -2872,8 +2872,8 @@ void FullscreenUI::DrawBIOSSettingsPage()
                     FSUI_CSTR("Patches the BIOS to skip the boot animation. Safe to enable."), "BIOS", "PatchFastBoot",
                     Settings::DEFAULT_FAST_BOOT_VALUE);
   DrawToggleSetting(bsi, FSUI_CSTR("Enable TTY Logging"),
-                    FSUI_CSTR("Logs BIOS calls to printf(). Not all games contain debugging messages."),
-                    "BIOS", "TTYLogging", false);
+                    FSUI_CSTR("Logs BIOS calls to printf(). Not all games contain debugging messages."), "BIOS",
+                    "TTYLogging", false);
 
   EndMenuButtons();
 }
@@ -4695,6 +4695,11 @@ void FullscreenUI::DrawAdvancedSettingsPage()
     bsi, FSUI_CSTR("Stretch Display Vertically"),
     FSUI_CSTR("Stretches the display to match the aspect ratio by multiplying vertically instead of horizontally."),
     "Display", "StretchVertically", false);
+  DrawEnumSetting(bsi, FSUI_CSTR("Wireframe Rendering"),
+                  FSUI_CSTR("Overlays or replaces normal triangle drawing with a wireframe/line view."), "GPU",
+                  "WireframeMode", GPUWireframeMode::Disabled, &Settings::ParseGPUWireframeMode,
+                  &Settings::GetGPUWireframeModeName, &Settings::GetGPUWireframeModeDisplayName,
+                  GPUWireframeMode::Count);
 
   MenuHeading(FSUI_CSTR("PGXP Settings"));
 
diff --git a/src/core/gpu_hw.cpp b/src/core/gpu_hw.cpp
index 28eb9d62c..1f62c1853 100644
--- a/src/core/gpu_hw.cpp
+++ b/src/core/gpu_hw.cpp
@@ -45,6 +45,11 @@ ALWAYS_INLINE static constexpr std::tuple<T, T> MinMax(T v1, T v2)
     return std::tie(v1, v2);
 }
 
+ALWAYS_INLINE static u32 GetMaxResolutionScale()
+{
+  return g_gpu_device->GetMaxTextureSize() / VRAM_WIDTH;
+}
+
 ALWAYS_INLINE static bool ShouldUseUVLimits()
 {
   // We only need UV limits if PGXP is enabled, or texture filtering is enabled.
@@ -57,7 +62,7 @@ ALWAYS_INLINE static bool ShouldDisableColorPerspective()
 }
 
 /// Returns true if the specified texture filtering mode requires dual-source blending.
-static bool TextureFilterRequiresDualSourceBlend(GPUTextureFilter filter)
+ALWAYS_INLINE static bool TextureFilterRequiresDualSourceBlend(GPUTextureFilter filter)
 {
   return (filter == GPUTextureFilter::Bilinear || filter == GPUTextureFilter::JINC2 || filter == GPUTextureFilter::xBR);
 }
@@ -148,42 +153,50 @@ bool GPU_HW::Initialize()
     return false;
 
   const GPUDevice::Features features = g_gpu_device->GetFeatures();
-  m_max_resolution_scale = g_gpu_device->GetMaxTextureSize() / VRAM_WIDTH;
-  m_supports_dual_source_blend = features.dual_source_blend;
-  m_supports_per_sample_shading = features.per_sample_shading;
-  m_supports_disable_color_perspective = features.noperspective_interpolation;
 
   m_resolution_scale = CalculateResolutionScale();
   m_multisamples = std::min(g_settings.gpu_multisamples, g_gpu_device->GetMaxMultisamples());
-  m_per_sample_shading = g_settings.gpu_per_sample_shading && m_supports_per_sample_shading;
+  m_per_sample_shading = g_settings.gpu_per_sample_shading && features.per_sample_shading;
   m_true_color = g_settings.gpu_true_color;
   m_scaled_dithering = g_settings.gpu_scaled_dithering;
   m_texture_filtering = g_settings.gpu_texture_filter;
   m_using_uv_limits = ShouldUseUVLimits();
   m_chroma_smoothing = g_settings.gpu_24bit_chroma_smoothing;
   m_downsample_mode = GetDownsampleMode(m_resolution_scale);
-  m_disable_color_perspective = m_supports_disable_color_perspective && ShouldDisableColorPerspective();
+  m_wireframe_mode = g_settings.gpu_wireframe_mode;
+  m_disable_color_perspective = features.noperspective_interpolation && ShouldDisableColorPerspective();
 
   if (m_multisamples != g_settings.gpu_multisamples)
   {
-    Host::AddFormattedOSDMessage(20.0f, TRANSLATE("OSDMessage", "%ux MSAA is not supported, using %ux instead."),
+    Host::AddFormattedOSDMessage(Host::OSD_CRITICAL_ERROR_DURATION,
+                                 TRANSLATE("OSDMessage", "%ux MSAA is not supported, using %ux instead."),
                                  g_settings.gpu_multisamples, m_multisamples);
   }
   if (!m_per_sample_shading && g_settings.gpu_per_sample_shading)
   {
     Host::AddOSDMessage(TRANSLATE_STR("OSDMessage", "SSAA is not supported, using MSAA instead."), 20.0f);
   }
-  if (!m_supports_dual_source_blend && TextureFilterRequiresDualSourceBlend(m_texture_filtering))
+  if (!features.dual_source_blend && TextureFilterRequiresDualSourceBlend(m_texture_filtering))
   {
     Host::AddFormattedOSDMessage(
-      20.0f, TRANSLATE("OSDMessage", "Texture filter '%s' is not supported with the current renderer."),
+      Host::OSD_CRITICAL_ERROR_DURATION,
+      TRANSLATE("OSDMessage", "Texture filter '%s' is not supported with the current renderer."),
       Settings::GetTextureFilterDisplayName(m_texture_filtering));
     m_texture_filtering = GPUTextureFilter::Nearest;
   }
 
-  if (!m_supports_disable_color_perspective && !ShouldDisableColorPerspective())
+  if (!features.noperspective_interpolation && !ShouldDisableColorPerspective())
     Log_WarningPrint("Disable color perspective not supported, but should be used.");
 
+  if (!features.geometry_shaders && m_wireframe_mode != GPUWireframeMode::Disabled)
+  {
+    Host::AddOSDMessage(
+      TRANSLATE("OSDMessage",
+                "Geometry shaders are not supported by your GPU, and are required for wireframe rendering."),
+      Host::OSD_CRITICAL_ERROR_DURATION);
+    m_wireframe_mode = GPUWireframeMode::Disabled;
+  }
+
   m_pgxp_depth_buffer = g_settings.UsingPGXPDepthBuffer();
 
   UpdateSoftwareRenderer(false);
@@ -290,12 +303,16 @@ void GPU_HW::UpdateSettings(const Settings& old_settings)
 {
   GPU::UpdateSettings(old_settings);
 
+  const GPUDevice::Features features = g_gpu_device->GetFeatures();
+
   const u32 resolution_scale = CalculateResolutionScale();
   const u32 multisamples = std::min(g_settings.gpu_multisamples, g_gpu_device->GetMaxMultisamples());
-  const bool per_sample_shading = g_settings.gpu_per_sample_shading && m_supports_per_sample_shading;
+  const bool per_sample_shading = g_settings.gpu_per_sample_shading && features.noperspective_interpolation;
   const GPUDownsampleMode downsample_mode = GetDownsampleMode(resolution_scale);
+  const GPUWireframeMode wireframe_mode =
+    features.geometry_shaders ? g_settings.gpu_wireframe_mode : GPUWireframeMode::Disabled;
   const bool use_uv_limits = ShouldUseUVLimits();
-  const bool disable_color_perspective = m_supports_disable_color_perspective && ShouldDisableColorPerspective();
+  const bool disable_color_perspective = features.noperspective_interpolation && ShouldDisableColorPerspective();
 
   // TODO: Use old_settings
   const bool framebuffer_changed =
@@ -305,7 +322,8 @@ void GPU_HW::UpdateSettings(const Settings& old_settings)
      m_true_color != g_settings.gpu_true_color || m_per_sample_shading != per_sample_shading ||
      m_scaled_dithering != g_settings.gpu_scaled_dithering || m_texture_filtering != g_settings.gpu_texture_filter ||
      m_using_uv_limits != use_uv_limits || m_chroma_smoothing != g_settings.gpu_24bit_chroma_smoothing ||
-     m_downsample_mode != downsample_mode || m_pgxp_depth_buffer != g_settings.UsingPGXPDepthBuffer() ||
+     m_downsample_mode != downsample_mode || m_wireframe_mode != wireframe_mode ||
+     m_pgxp_depth_buffer != g_settings.UsingPGXPDepthBuffer() ||
      m_disable_color_perspective != disable_color_perspective);
 
   if (m_resolution_scale != resolution_scale)
@@ -348,6 +366,7 @@ void GPU_HW::UpdateSettings(const Settings& old_settings)
   m_using_uv_limits = use_uv_limits;
   m_chroma_smoothing = g_settings.gpu_24bit_chroma_smoothing;
   m_downsample_mode = downsample_mode;
+  m_wireframe_mode = wireframe_mode;
   m_disable_color_perspective = disable_color_perspective;
 
   if (!m_supports_dual_source_blend && TextureFilterRequiresDualSourceBlend(m_texture_filtering))
@@ -387,10 +406,12 @@ void GPU_HW::UpdateSettings(const Settings& old_settings)
 
 u32 GPU_HW::CalculateResolutionScale() const
 {
+  const u32 max_resolution_scale = GetMaxResolutionScale();
+
   u32 scale;
   if (g_settings.gpu_resolution_scale != 0)
   {
-    scale = std::clamp<u32>(g_settings.gpu_resolution_scale, 1, m_max_resolution_scale);
+    scale = std::clamp<u32>(g_settings.gpu_resolution_scale, 1, max_resolution_scale);
   }
   else
   {
@@ -404,7 +425,7 @@ u32 GPU_HW::CalculateResolutionScale() const
       static_cast<s32>(std::ceil(static_cast<float>(g_gpu_device->GetWindowHeight()) / height));
     Log_InfoPrintf("Height = %d, preferred scale = %d", height, preferred_scale);
 
-    scale = static_cast<u32>(std::clamp<s32>(preferred_scale, 1, m_max_resolution_scale));
+    scale = static_cast<u32>(std::clamp<s32>(preferred_scale, 1, max_resolution_scale));
   }
 
   if (g_settings.gpu_downsample_mode == GPUDownsampleMode::Adaptive && scale > 1 && !Common::IsPow2(scale))
@@ -474,7 +495,7 @@ std::tuple<u32, u32> GPU_HW::GetFullDisplayResolution(bool scaled /* = true */)
 void GPU_HW::PrintSettingsToLog()
 {
   Log_InfoPrintf("Resolution Scale: %u (%ux%u), maximum %u", m_resolution_scale, VRAM_WIDTH * m_resolution_scale,
-                 VRAM_HEIGHT * m_resolution_scale, m_max_resolution_scale);
+                 VRAM_HEIGHT * m_resolution_scale, GetMaxResolutionScale());
   Log_InfoPrintf("Multisampling: %ux%s", m_multisamples, m_per_sample_shading ? " (per sample shading)" : "");
   Log_InfoPrintf("Dithering: %s%s", m_true_color ? "Disabled" : "Enabled",
                  (!m_true_color && m_scaled_dithering) ? " (Scaled)" : "");
@@ -483,6 +504,7 @@ void GPU_HW::PrintSettingsToLog()
   Log_InfoPrintf("Using UV limits: %s", m_using_uv_limits ? "YES" : "NO");
   Log_InfoPrintf("Depth buffer: %s", m_pgxp_depth_buffer ? "YES" : "NO");
   Log_InfoPrintf("Downsampling: %s", Settings::GetDownsampleModeDisplayName(m_downsample_mode));
+  Log_InfoPrintf("Wireframe rendering: %s", Settings::GetGPUWireframeModeDisplayName(m_wireframe_mode));
   Log_InfoPrintf("Using software renderer for readbacks: %s", m_sw_renderer ? "YES" : "NO");
 }
 
@@ -772,6 +794,39 @@ bool GPU_HW::CompilePipelines()
     }
   }
 
+  if (m_wireframe_mode != GPUWireframeMode::Disabled)
+  {
+    std::unique_ptr<GPUShader> gs =
+      g_gpu_device->CreateShader(GPUShaderStage::Geometry, shadergen.GenerateWireframeGeometryShader());
+    std::unique_ptr<GPUShader> fs =
+      g_gpu_device->CreateShader(GPUShaderStage::Fragment, shadergen.GenerateWireframeFragmentShader());
+    if (!gs || !fs)
+      return false;
+
+    GL_OBJECT_NAME(gs, "Batch Wireframe Geometry Shader");
+    GL_OBJECT_NAME(fs, "Batch Wireframe Fragment Shader");
+
+    plconfig.input_layout.vertex_attributes =
+      gsl::span<const GPUPipeline::VertexAttribute>(vertex_attributes, NUM_BATCH_VERTEX_ATTRIBUTES);
+    plconfig.blend = (m_wireframe_mode == GPUWireframeMode::OverlayWireframe) ?
+                       GPUPipeline::BlendState::GetAlphaBlendingState() :
+                       GPUPipeline::BlendState::GetNoBlendingState();
+    plconfig.blend.write_mask = 0x7;
+    plconfig.depth = GPUPipeline::DepthState::GetNoTestsState();
+    plconfig.vertex_shader = batch_vertex_shaders[0].get();
+    plconfig.geometry_shader = gs.get();
+    plconfig.fragment_shader = fs.get();
+
+    if (!(m_wireframe_pipeline = g_gpu_device->CreatePipeline(plconfig)))
+      return false;
+
+    GL_OBJECT_NAME(m_wireframe_pipeline, "Batch Wireframe Pipeline");
+
+    plconfig.vertex_shader = nullptr;
+    plconfig.geometry_shader = nullptr;
+    plconfig.fragment_shader = nullptr;
+  }
+
   batch_shader_guard.Run();
 
   std::unique_ptr<GPUShader> fullscreen_quad_vertex_shader =
@@ -1026,6 +1081,8 @@ void GPU_HW::DestroyPipelines()
 {
   static constexpr auto destroy = [](std::unique_ptr<GPUPipeline>& p) { p.reset(); };
 
+  m_wireframe_pipeline.reset();
+
   m_batch_pipelines.enumerate(destroy);
 
   m_vram_fill_pipelines.enumerate(destroy);
@@ -1136,7 +1193,7 @@ void GPU_HW::UnmapBatchVertexPointer(u32 used_vertices)
   m_batch_current_vertex_ptr = nullptr;
 }
 
-void GPU_HW::DrawBatchVertices(BatchRenderMode render_mode, u32 base_vertex, u32 num_vertices)
+void GPU_HW::DrawBatchVertices(BatchRenderMode render_mode, u32 num_vertices, u32 base_vertex)
 {
   // [depth_test][render_mode][texture_mode][transparency_mode][dithering][interlacing]
   const u8 depth_test = m_batch.use_depth_buffer ? static_cast<u8>(2) : BoolToUInt8(m_batch.check_mask_before_draw);
@@ -2365,16 +2422,26 @@ void GPU_HW::FlushRender()
     m_batch_ubo_dirty = false;
   }
 
-  if (NeedsTwoPassRendering())
+  if (m_wireframe_mode != GPUWireframeMode::OnlyWireframe)
   {
-    m_renderer_stats.num_batches += 2;
-    DrawBatchVertices(BatchRenderMode::OnlyOpaque, m_batch_base_vertex, vertex_count);
-    DrawBatchVertices(BatchRenderMode::OnlyTransparent, m_batch_base_vertex, vertex_count);
+    if (NeedsTwoPassRendering())
+    {
+      m_renderer_stats.num_batches += 2;
+      DrawBatchVertices(BatchRenderMode::OnlyOpaque, vertex_count, m_batch_base_vertex);
+      DrawBatchVertices(BatchRenderMode::OnlyTransparent, vertex_count, m_batch_base_vertex);
+    }
+    else
+    {
+      m_renderer_stats.num_batches++;
+      DrawBatchVertices(m_batch.GetRenderMode(), vertex_count, m_batch_base_vertex);
+    }
   }
-  else
+
+  if (m_wireframe_mode != GPUWireframeMode::Disabled)
   {
     m_renderer_stats.num_batches++;
-    DrawBatchVertices(m_batch.GetRenderMode(), m_batch_base_vertex, vertex_count);
+    g_gpu_device->SetPipeline(m_wireframe_pipeline.get());
+    g_gpu_device->Draw(vertex_count, m_batch_base_vertex);
   }
 }
 
diff --git a/src/core/gpu_hw.h b/src/core/gpu_hw.h
index 8dd320d83..edf25862a 100644
--- a/src/core/gpu_hw.h
+++ b/src/core/gpu_hw.h
@@ -157,7 +157,7 @@ private:
   void SetScissor();
   void MapBatchVertexPointer(u32 required_vertices);
   void UnmapBatchVertexPointer(u32 used_vertices);
-  void DrawBatchVertices(BatchRenderMode render_mode, u32 base_vertex, u32 num_vertices);
+  void DrawBatchVertices(BatchRenderMode render_mode, u32 num_vertices, u32 base_vertex);
   void ClearDisplay() override;
   void UpdateDisplay() override;
 
@@ -266,24 +266,22 @@ private:
 
   u32 m_resolution_scale = 1;
   u32 m_multisamples = 1;
-  u32 m_max_resolution_scale = 1;
-  bool m_true_color = true;
 
   union
   {
-    BitField<u8, bool, 0, 1> m_supports_per_sample_shading;
-    BitField<u8, bool, 1, 1> m_supports_dual_source_blend;
-    BitField<u8, bool, 2, 1> m_supports_disable_color_perspective;
-    BitField<u8, bool, 3, 1> m_per_sample_shading;
-    BitField<u8, bool, 4, 1> m_scaled_dithering;
-    BitField<u8, bool, 5, 1> m_chroma_smoothing;
-    BitField<u8, bool, 6, 1> m_disable_color_perspective;
+    BitField<u8, bool, 0, 1> m_supports_dual_source_blend;
+    BitField<u8, bool, 1, 1> m_per_sample_shading;
+    BitField<u8, bool, 2, 1> m_scaled_dithering;
+    BitField<u8, bool, 3, 1> m_chroma_smoothing;
+    BitField<u8, bool, 4, 1> m_disable_color_perspective;
 
     u8 bits = 0;
   };
 
   GPUTextureFilter m_texture_filtering = GPUTextureFilter::Nearest;
   GPUDownsampleMode m_downsample_mode = GPUDownsampleMode::Disabled;
+  GPUWireframeMode m_wireframe_mode = GPUWireframeMode::Disabled;
+  bool m_true_color = true;
   bool m_using_uv_limits = false;
   bool m_pgxp_depth_buffer = false;
 
@@ -298,6 +296,7 @@ private:
 
   // [depth_test][render_mode][texture_mode][transparency_mode][dithering][interlacing]
   DimensionalArray<std::unique_ptr<GPUPipeline>, 2, 2, 5, 9, 4, 3> m_batch_pipelines{};
+  std::unique_ptr<GPUPipeline> m_wireframe_pipeline;
 
   // [wrapped][interlaced]
   DimensionalArray<std::unique_ptr<GPUPipeline>, 2, 2> m_vram_fill_pipelines{};
diff --git a/src/core/gpu_hw_shadergen.cpp b/src/core/gpu_hw_shadergen.cpp
index bc32c2ed4..ded3bccf0 100644
--- a/src/core/gpu_hw_shadergen.cpp
+++ b/src/core/gpu_hw_shadergen.cpp
@@ -1087,6 +1087,96 @@ float3 SampleVRAM24Smoothed(uint2 icoords)
   return ss.str();
 }
 
+std::string GPU_HW_ShaderGen::GenerateWireframeGeometryShader()
+{
+  std::stringstream ss;
+  WriteHeader(ss);
+  WriteCommonFunctions(ss);
+
+  if (m_glsl)
+  {
+    ss << R"(
+layout(triangles) in;
+layout(line_strip, max_vertices = 6) out;
+
+void main()
+{
+  gl_Position = gl_in[0].gl_Position;
+  EmitVertex();
+  gl_Position = gl_in[1].gl_Position;
+  EmitVertex();
+  EndPrimitive();
+  gl_Position = gl_in[1].gl_Position;
+  EmitVertex();
+  gl_Position = gl_in[2].gl_Position;
+  EmitVertex();
+  EndPrimitive();
+  gl_Position = gl_in[2].gl_Position;
+  EmitVertex();
+  gl_Position = gl_in[0].gl_Position;
+  EmitVertex();
+  EndPrimitive();
+}
+)";
+  }
+  else
+  {
+    ss << R"(
+struct GSInput
+{
+  float4 col0 : COLOR0;
+  float4 pos : SV_Position;
+};
+
+struct GSOutput
+{
+  float4 pos : SV_Position;
+};
+
+GSOutput GetVertex(GSInput vi)
+{
+  GSOutput vo;
+  vo.pos = vi.pos;
+  return vo;
+}
+
+[maxvertexcount(6)]
+void main(triangle GSInput input[3], inout LineStream<GSOutput> output)
+{
+  output.Append(GetVertex(input[0]));
+  output.Append(GetVertex(input[1]));
+  output.RestartStrip();
+
+  output.Append(GetVertex(input[1]));
+  output.Append(GetVertex(input[2]));
+  output.RestartStrip();
+
+  output.Append(GetVertex(input[2]));
+  output.Append(GetVertex(input[0]));
+  output.RestartStrip();
+}
+)";
+  }
+
+  return ss.str();
+}
+
+std::string GPU_HW_ShaderGen::GenerateWireframeFragmentShader()
+{
+  std::stringstream ss;
+  WriteHeader(ss);
+  WriteCommonFunctions(ss);
+
+  DeclareFragmentEntryPoint(ss, 0, 0, {}, false, 1);
+  ss << R"(
+{
+  o_col0 = float4(1.0, 1.0, 1.0, 0.5);
+}
+)";
+
+  return ss.str();
+}
+
 std::string GPU_HW_ShaderGen::GenerateVRAMReadFragmentShader()
 {
   std::stringstream ss;
diff --git a/src/core/gpu_hw_shadergen.h b/src/core/gpu_hw_shadergen.h
index 8254c8fe3..82fd5a49d 100644
--- a/src/core/gpu_hw_shadergen.h
+++ b/src/core/gpu_hw_shadergen.h
@@ -18,6 +18,8 @@ public:
                                           bool dithering, bool interlacing);
   std::string GenerateDisplayFragmentShader(bool depth_24bit, GPU_HW::InterlacedRenderMode interlace_mode,
                                             bool smooth_chroma);
+  std::string GenerateWireframeGeometryShader();
+  std::string GenerateWireframeFragmentShader();
   std::string GenerateVRAMReadFragmentShader();
   std::string GenerateVRAMWriteFragmentShader(bool use_ssbo);
   std::string GenerateVRAMCopyFragmentShader();
diff --git a/src/core/settings.cpp b/src/core/settings.cpp
index 2cf782936..6e39f6393 100644
--- a/src/core/settings.cpp
+++ b/src/core/settings.cpp
@@ -161,7 +161,7 @@ void Settings::Load(SettingsInterface& si)
   region =
     ParseConsoleRegionName(
       si.GetStringValue("Console", "Region", Settings::GetConsoleRegionName(Settings::DEFAULT_CONSOLE_REGION)).c_str())
-    .value_or(DEFAULT_CONSOLE_REGION);
+      .value_or(DEFAULT_CONSOLE_REGION);
   enable_8mb_ram = si.GetBoolValue("Console", "Enable8MBRAM", false);
 
   emulation_speed = si.GetFloatValue("Main", "EmulationSpeed", 1.0f);
@@ -192,7 +192,7 @@ void Settings::Load(SettingsInterface& si)
   cpu_execution_mode =
     ParseCPUExecutionMode(
       si.GetStringValue("CPU", "ExecutionMode", GetCPUExecutionModeName(DEFAULT_CPU_EXECUTION_MODE)).c_str())
-    .value_or(DEFAULT_CPU_EXECUTION_MODE);
+      .value_or(DEFAULT_CPU_EXECUTION_MODE);
   cpu_overclock_numerator = std::max(si.GetIntValue("CPU", "OverclockNumerator", 1), 1);
   cpu_overclock_denominator = std::max(si.GetIntValue("CPU", "OverclockDenominator", 1), 1);
   cpu_overclock_enable = si.GetBoolValue("CPU", "OverclockEnable", false);
@@ -201,11 +201,11 @@ void Settings::Load(SettingsInterface& si)
   cpu_recompiler_block_linking = si.GetBoolValue("CPU", "RecompilerBlockLinking", true);
   cpu_recompiler_icache = si.GetBoolValue("CPU", "RecompilerICache", false);
   cpu_fastmem_mode = ParseCPUFastmemMode(
-    si.GetStringValue("CPU", "FastmemMode", GetCPUFastmemModeName(DEFAULT_CPU_FASTMEM_MODE)).c_str())
-    .value_or(DEFAULT_CPU_FASTMEM_MODE);
+                       si.GetStringValue("CPU", "FastmemMode", GetCPUFastmemModeName(DEFAULT_CPU_FASTMEM_MODE)).c_str())
+                       .value_or(DEFAULT_CPU_FASTMEM_MODE);
 
   gpu_renderer = ParseRendererName(si.GetStringValue("GPU", "Renderer", GetRendererName(DEFAULT_GPU_RENDERER)).c_str())
-    .value_or(DEFAULT_GPU_RENDERER);
+                   .value_or(DEFAULT_GPU_RENDERER);
   gpu_adapter = si.GetStringValue("GPU", "Adapter", "");
   gpu_resolution_scale = static_cast<u32>(si.GetIntValue("GPU", "ResolutionScale", 1));
   gpu_multisamples = static_cast<u32>(si.GetIntValue("GPU", "Multisamples", 1));
@@ -220,11 +220,15 @@ void Settings::Load(SettingsInterface& si)
   gpu_texture_filter =
     ParseTextureFilterName(
       si.GetStringValue("GPU", "TextureFilter", GetTextureFilterName(DEFAULT_GPU_TEXTURE_FILTER)).c_str())
-    .value_or(DEFAULT_GPU_TEXTURE_FILTER);
+      .value_or(DEFAULT_GPU_TEXTURE_FILTER);
   gpu_downsample_mode =
     ParseDownsampleModeName(
       si.GetStringValue("GPU", "DownsampleMode", GetDownsampleModeName(DEFAULT_GPU_DOWNSAMPLE_MODE)).c_str())
-    .value_or(DEFAULT_GPU_DOWNSAMPLE_MODE);
+      .value_or(DEFAULT_GPU_DOWNSAMPLE_MODE);
+  gpu_wireframe_mode =
+    ParseGPUWireframeMode(
+      si.GetStringValue("GPU", "WireframeMode", GetGPUWireframeModeName(DEFAULT_GPU_WIREFRAME_MODE)).c_str())
+      .value_or(DEFAULT_GPU_WIREFRAME_MODE);
   gpu_disable_interlacing = si.GetBoolValue("GPU", "DisableInterlacing", true);
   gpu_force_ntsc_timings = si.GetBoolValue("GPU", "ForceNTSCTimings", false);
   gpu_widescreen_hack = si.GetBoolValue("GPU", "WidescreenHack", false);
@@ -243,11 +247,11 @@ void Settings::Load(SettingsInterface& si)
   display_crop_mode =
     ParseDisplayCropMode(
       si.GetStringValue("Display", "CropMode", GetDisplayCropModeName(DEFAULT_DISPLAY_CROP_MODE)).c_str())
-    .value_or(DEFAULT_DISPLAY_CROP_MODE);
+      .value_or(DEFAULT_DISPLAY_CROP_MODE);
   display_aspect_ratio =
     ParseDisplayAspectRatio(
       si.GetStringValue("Display", "AspectRatio", GetDisplayAspectRatioName(DEFAULT_DISPLAY_ASPECT_RATIO)).c_str())
-    .value_or(DEFAULT_DISPLAY_ASPECT_RATIO);
+      .value_or(DEFAULT_DISPLAY_ASPECT_RATIO);
   display_aspect_ratio_custom_numerator = static_cast<u16>(
     std::clamp<int>(si.GetIntValue("Display", "CustomAspectRatioNumerator", 4), 1, std::numeric_limits<u16>::max()));
   display_aspect_ratio_custom_denominator = static_cast<u16>(
@@ -255,10 +259,10 @@ void Settings::Load(SettingsInterface& si)
   display_alignment =
     ParseDisplayAlignment(
       si.GetStringValue("Display", "Alignment", GetDisplayAlignmentName(DEFAULT_DISPLAY_ALIGNMENT)).c_str())
-    .value_or(DEFAULT_DISPLAY_ALIGNMENT);
+      .value_or(DEFAULT_DISPLAY_ALIGNMENT);
   display_scaling =
     ParseDisplayScaling(si.GetStringValue("Display", "Scaling", GetDisplayScalingName(DEFAULT_DISPLAY_SCALING)).c_str())
-    .value_or(DEFAULT_DISPLAY_SCALING);
+      .value_or(DEFAULT_DISPLAY_SCALING);
   display_force_4_3_for_24bit = si.GetBoolValue("Display", "Force4_3For24Bit", false);
   display_active_start_offset = static_cast<s16>(si.GetIntValue("Display", "ActiveStartOffset", 0));
   display_active_end_offset = static_cast<s16>(si.GetIntValue("Display", "ActiveEndOffset", 0));
@@ -292,13 +296,13 @@ void Settings::Load(SettingsInterface& si)
 
   audio_backend =
     ParseAudioBackend(si.GetStringValue("Audio", "Backend", GetAudioBackendName(DEFAULT_AUDIO_BACKEND)).c_str())
-    .value_or(DEFAULT_AUDIO_BACKEND);
+      .value_or(DEFAULT_AUDIO_BACKEND);
   audio_driver = si.GetStringValue("Audio", "Driver");
   audio_output_device = si.GetStringValue("Audio", "OutputDevice");
   audio_stretch_mode =
     AudioStream::ParseStretchMode(
       si.GetStringValue("Audio", "StretchMode", AudioStream::GetStretchModeName(DEFAULT_AUDIO_STRETCH_MODE)).c_str())
-    .value_or(DEFAULT_AUDIO_STRETCH_MODE);
+      .value_or(DEFAULT_AUDIO_STRETCH_MODE);
   audio_output_latency_ms = si.GetUIntValue("Audio", "OutputLatencyMS", DEFAULT_AUDIO_OUTPUT_LATENCY_MS);
   audio_buffer_ms = si.GetUIntValue("Audio", "BufferMS", DEFAULT_AUDIO_BUFFER_MS);
   audio_output_volume = si.GetUIntValue("Audio", "OutputVolume", 100);
@@ -323,14 +327,14 @@ void Settings::Load(SettingsInterface& si)
   multitap_mode =
     ParseMultitapModeName(
       si.GetStringValue("ControllerPorts", "MultitapMode", GetMultitapModeName(DEFAULT_MULTITAP_MODE)).c_str())
-    .value_or(DEFAULT_MULTITAP_MODE);
+      .value_or(DEFAULT_MULTITAP_MODE);
 
   controller_types[0] = ParseControllerTypeName(si.GetStringValue(Controller::GetSettingsSection(0).c_str(), "Type",
-    GetControllerTypeName(DEFAULT_CONTROLLER_1_TYPE))
-    .c_str())
-    .value_or(DEFAULT_CONTROLLER_1_TYPE);
+                                                                  GetControllerTypeName(DEFAULT_CONTROLLER_1_TYPE))
+                                                  .c_str())
+                          .value_or(DEFAULT_CONTROLLER_1_TYPE);
 
-  const std::array<bool, 2> mtap_enabled = { {IsPort1MultitapEnabled(), IsPort2MultitapEnabled()} };
+  const std::array<bool, 2> mtap_enabled = {{IsPort1MultitapEnabled(), IsPort2MultitapEnabled()}};
   for (u32 i = 1; i < NUM_CONTROLLER_AND_CARD_PORTS; i++)
   {
     // Ignore types when multitap not enabled
@@ -342,19 +346,19 @@ void Settings::Load(SettingsInterface& si)
     }
 
     controller_types[i] = ParseControllerTypeName(si.GetStringValue(Controller::GetSettingsSection(i).c_str(), "Type",
-      GetControllerTypeName(DEFAULT_CONTROLLER_2_TYPE))
-      .c_str())
-      .value_or(DEFAULT_CONTROLLER_2_TYPE);
+                                                                    GetControllerTypeName(DEFAULT_CONTROLLER_2_TYPE))
+                                                    .c_str())
+                            .value_or(DEFAULT_CONTROLLER_2_TYPE);
   }
 
   memory_card_types[0] =
     ParseMemoryCardTypeName(
       si.GetStringValue("MemoryCards", "Card1Type", GetMemoryCardTypeName(DEFAULT_MEMORY_CARD_1_TYPE)).c_str())
-    .value_or(DEFAULT_MEMORY_CARD_1_TYPE);
+      .value_or(DEFAULT_MEMORY_CARD_1_TYPE);
   memory_card_types[1] =
     ParseMemoryCardTypeName(
       si.GetStringValue("MemoryCards", "Card2Type", GetMemoryCardTypeName(DEFAULT_MEMORY_CARD_2_TYPE)).c_str())
-    .value_or(DEFAULT_MEMORY_CARD_2_TYPE);
+      .value_or(DEFAULT_MEMORY_CARD_2_TYPE);
   memory_card_paths[0] = si.GetStringValue("MemoryCards", "Card1Path", "");
   memory_card_paths[1] = si.GetStringValue("MemoryCards", "Card2Path", "");
   memory_card_use_playlist_title = si.GetBoolValue("MemoryCards", "UsePlaylistTitle", true);
@@ -372,7 +376,7 @@ void Settings::Load(SettingsInterface& si)
   achievements_use_raintegration = si.GetBoolValue("Cheevos", "UseRAIntegration", false);
 
   log_level = ParseLogLevelName(si.GetStringValue("Logging", "LogLevel", GetLogLevelName(DEFAULT_LOG_LEVEL)).c_str())
-    .value_or(DEFAULT_LOG_LEVEL);
+                .value_or(DEFAULT_LOG_LEVEL);
   log_filter = si.GetStringValue("Logging", "LogFilter", "");
   log_to_console = si.GetBoolValue("Logging", "LogToConsole", DEFAULT_LOG_TO_CONSOLE);
   log_to_debug = si.GetBoolValue("Logging", "LogToDebug", false);
@@ -462,6 +466,7 @@ void Settings::Save(SettingsInterface& si) const
   si.SetBoolValue("GPU", "ScaledDithering", gpu_scaled_dithering);
   si.SetStringValue("GPU", "TextureFilter", GetTextureFilterName(gpu_texture_filter));
   si.SetStringValue("GPU", "DownsampleMode", GetDownsampleModeName(gpu_downsample_mode));
+  si.SetStringValue("GPU", "WireframeMode", GetGPUWireframeModeName(gpu_wireframe_mode));
   si.SetBoolValue("GPU", "DisableInterlacing", gpu_disable_interlacing);
   si.SetBoolValue("GPU", "ForceNTSCTimings", gpu_force_ntsc_timings);
   si.SetBoolValue("GPU", "WidescreenHack", gpu_widescreen_hack);
@@ -1052,6 +1057,35 @@ const char* Settings::GetDownsampleModeDisplayName(GPUDownsampleMode mode)
   return Host::TranslateToCString("GPUDownsampleMode", s_downsample_mode_display_names[static_cast<int>(mode)]);
 }
 
+static constexpr auto s_wireframe_mode_names = make_array("Disabled", "OverlayWireframe", "OnlyWireframe");
+static constexpr auto s_wireframe_mode_display_names =
+  make_array(TRANSLATE_NOOP("GPUWireframeMode", "Disabled"), TRANSLATE_NOOP("GPUWireframeMode", "Overlay Wireframe"),
+             TRANSLATE_NOOP("GPUWireframeMode", "Only Wireframe"));
+
+std::optional<GPUWireframeMode> Settings::ParseGPUWireframeMode(const char* str)
+{
+  int index = 0;
+  for (const char* name : s_wireframe_mode_names)
+  {
+    if (StringUtil::Strcasecmp(name, str) == 0)
+      return static_cast<GPUWireframeMode>(index);
+
+    index++;
+  }
+
+  return std::nullopt;
+}
+
+const char* Settings::GetGPUWireframeModeName(GPUWireframeMode mode)
+{
+  return s_wireframe_mode_names[static_cast<int>(mode)];
+}
+
+const char* Settings::GetGPUWireframeModeDisplayName(GPUWireframeMode mode)
+{
+  return Host::TranslateToCString("GPUWireframeMode", s_wireframe_mode_display_names[static_cast<int>(mode)]);
+}
+
 static std::array<const char*, 3> s_display_crop_mode_names = {{"None", "Overscan", "Borders"}};
 static std::array<const char*, 3> s_display_crop_mode_display_names = {
   {TRANSLATE_NOOP("DisplayCropMode", "None"), TRANSLATE_NOOP("DisplayCropMode", "Only Overscan Area"),
@@ -1082,9 +1116,8 @@ const char* Settings::GetDisplayCropModeDisplayName(DisplayCropMode crop_mode)
 }
 
 static std::array<const char*, static_cast<size_t>(DisplayAspectRatio::Count)> s_display_aspect_ratio_names = {
-  {TRANSLATE_NOOP("DisplayAspectRatio", "Auto (Game Native)"),
-   TRANSLATE_NOOP("DisplayAspectRatio", "Stretch To Fill"), TRANSLATE_NOOP("DisplayAspectRatio", "Custom"), "4:3",
-   "16:9", "19:9", "20:9", "PAR 1:1"}};
+  {TRANSLATE_NOOP("DisplayAspectRatio", "Auto (Game Native)"), TRANSLATE_NOOP("DisplayAspectRatio", "Stretch To Fill"),
+   TRANSLATE_NOOP("DisplayAspectRatio", "Custom"), "4:3", "16:9", "19:9", "20:9", "PAR 1:1"}};
 static constexpr std::array<float, static_cast<size_t>(DisplayAspectRatio::Count)> s_display_aspect_ratio_values = {
   {-1.0f, -1.0f, -1.0f, 4.0f / 3.0f, 16.0f / 9.0f, 19.0f / 9.0f, 20.0f / 9.0f, -1.0f}};
 
diff --git a/src/core/settings.h b/src/core/settings.h
index bd0a4b596..bb20cef8d 100644
--- a/src/core/settings.h
+++ b/src/core/settings.h
@@ -106,6 +106,7 @@ struct Settings
   bool gpu_scaled_dithering = true;
   GPUTextureFilter gpu_texture_filter = DEFAULT_GPU_TEXTURE_FILTER;
   GPUDownsampleMode gpu_downsample_mode = DEFAULT_GPU_DOWNSAMPLE_MODE;
+  GPUWireframeMode gpu_wireframe_mode = DEFAULT_GPU_WIREFRAME_MODE;
   bool gpu_disable_interlacing = true;
   bool gpu_force_ntsc_timings = false;
   bool gpu_widescreen_hack = false;
@@ -373,6 +374,10 @@ struct Settings
   static const char* GetDownsampleModeName(GPUDownsampleMode mode);
   static const char* GetDownsampleModeDisplayName(GPUDownsampleMode mode);
 
+  static std::optional<GPUWireframeMode> ParseGPUWireframeMode(const char* str);
+  static const char* GetGPUWireframeModeName(GPUWireframeMode mode);
+  static const char* GetGPUWireframeModeDisplayName(GPUWireframeMode mode);
+
   static std::optional<DisplayCropMode> ParseDisplayCropMode(const char* str);
   static const char* GetDisplayCropModeName(DisplayCropMode crop_mode);
   static const char* GetDisplayCropModeDisplayName(DisplayCropMode crop_mode);
@@ -421,6 +426,7 @@ struct Settings
 #endif
   static constexpr GPUTextureFilter DEFAULT_GPU_TEXTURE_FILTER = GPUTextureFilter::Nearest;
   static constexpr GPUDownsampleMode DEFAULT_GPU_DOWNSAMPLE_MODE = GPUDownsampleMode::Disabled;
+  static constexpr GPUWireframeMode DEFAULT_GPU_WIREFRAME_MODE = GPUWireframeMode::Disabled;
   static constexpr ConsoleRegion DEFAULT_CONSOLE_REGION = ConsoleRegion::Auto;
   static constexpr float DEFAULT_GPU_PGXP_DEPTH_THRESHOLD = 300.0f;
   static constexpr float GPU_PGXP_DEPTH_THRESHOLD_SCALE = 4096.0f;
diff --git a/src/core/system.cpp b/src/core/system.cpp
index ad05f0f2c..d7bda6928 100644
--- a/src/core/system.cpp
+++ b/src/core/system.cpp
@@ -3589,6 +3589,7 @@ void System::CheckForSettingsChanges(const Settings& old_settings)
         g_settings.gpu_force_ntsc_timings != old_settings.gpu_force_ntsc_timings ||
         g_settings.gpu_24bit_chroma_smoothing != old_settings.gpu_24bit_chroma_smoothing ||
         g_settings.gpu_downsample_mode != old_settings.gpu_downsample_mode ||
+        g_settings.gpu_wireframe_mode != old_settings.gpu_wireframe_mode ||
         g_settings.display_crop_mode != old_settings.display_crop_mode ||
         g_settings.display_aspect_ratio != old_settings.display_aspect_ratio ||
         g_settings.display_alignment != old_settings.display_alignment ||
diff --git a/src/core/types.h b/src/core/types.h
index 91ac0c35d..a67ff2c12 100644
--- a/src/core/types.h
+++ b/src/core/types.h
@@ -95,6 +95,14 @@ enum class GPUDownsampleMode : u8
   Count
 };
 
+enum class GPUWireframeMode : u8
+{
+  Disabled,
+  OverlayWireframe,
+  OnlyWireframe,
+  Count,
+};
+
 enum class DisplayCropMode : u8
 {
   None,
diff --git a/src/duckstation-qt/advancedsettingswidget.cpp b/src/duckstation-qt/advancedsettingswidget.cpp
index 67e032cb1..24c56215a 100644
--- a/src/duckstation-qt/advancedsettingswidget.cpp
+++ b/src/duckstation-qt/advancedsettingswidget.cpp
@@ -279,6 +279,11 @@ void AdvancedSettingsWidget::addTweakOptions()
 
   addMSAATweakOption(m_dialog, m_ui.tweakOptionTable, tr("Multisample Antialiasing"));
 
+  addChoiceTweakOption(m_dialog, m_ui.tweakOptionTable, tr("Wireframe Mode"), "GPU", "WireframeMode",
+                       Settings::ParseGPUWireframeMode, Settings::GetGPUWireframeModeName,
+                       Settings::GetGPUWireframeModeDisplayName, static_cast<u32>(GPUWireframeMode::Count),
+                       GPUWireframeMode::Disabled);
+
   if (m_dialog->isPerGameSettings())
   {
     addIntRangeTweakOption(m_dialog, m_ui.tweakOptionTable, tr("Display Active Start Offset"), "Display",
@@ -365,6 +370,7 @@ void AdvancedSettingsWidget::onResetToDefaultClicked()
     setBooleanTweakOption(m_ui.tweakOptionTable, i++, true);     // Apply compatibility settings
     setIntRangeTweakOption(m_ui.tweakOptionTable, i++, 0);       // Display FPS limit
     setChoiceTweakOption(m_ui.tweakOptionTable, i++, 0);         // Multisample antialiasing
+    setChoiceTweakOption(m_ui.tweakOptionTable, i++, 0);         // Wireframe mode
     setBooleanTweakOption(m_ui.tweakOptionTable, i++, false);    // PGXP vertex cache
     setFloatRangeTweakOption(m_ui.tweakOptionTable, i++, -1.0f); // PGXP geometry tolerance
     setFloatRangeTweakOption(m_ui.tweakOptionTable, i++,
diff --git a/src/util/shadergen.cpp b/src/util/shadergen.cpp
index a2de5908e..8fe06f167 100644
--- a/src/util/shadergen.cpp
+++ b/src/util/shadergen.cpp
@@ -489,41 +489,44 @@ void ShaderGen::DeclareFragmentEntryPoint(
 {
   if (m_glsl)
   {
-    if (m_use_glsl_interface_blocks)
+    if (num_color_inputs > 0 || num_texcoord_inputs > 0 || additional_inputs.size() > 0)
     {
-      const char* qualifier = GetInterpolationQualifier(true, msaa, ssaa, false);
-
-      if (m_spirv)
-        ss << "layout(location = 0) ";
-
-      ss << "in VertexData {\n";
-      for (u32 i = 0; i < num_color_inputs; i++)
-        ss << "  " << qualifier << (noperspective_color ? "noperspective " : "") << "float4 v_col" << i << ";\n";
-
-      for (u32 i = 0; i < num_texcoord_inputs; i++)
-        ss << "  " << qualifier << "float2 v_tex" << i << ";\n";
-
-      for (const auto& [qualifiers, name] : additional_inputs)
+      if (m_use_glsl_interface_blocks)
       {
-        const char* qualifier_to_use = (std::strlen(qualifiers) > 0) ? qualifiers : qualifier;
-        ss << "  " << qualifier_to_use << " " << name << ";\n";
+        const char* qualifier = GetInterpolationQualifier(true, msaa, ssaa, false);
+
+        if (m_spirv)
+          ss << "layout(location = 0) ";
+
+        ss << "in VertexData {\n";
+        for (u32 i = 0; i < num_color_inputs; i++)
+          ss << "  " << qualifier << (noperspective_color ? "noperspective " : "") << "float4 v_col" << i << ";\n";
+
+        for (u32 i = 0; i < num_texcoord_inputs; i++)
+          ss << "  " << qualifier << "float2 v_tex" << i << ";\n";
+
+        for (const auto& [qualifiers, name] : additional_inputs)
+        {
+          const char* qualifier_to_use = (std::strlen(qualifiers) > 0) ? qualifiers : qualifier;
+          ss << "  " << qualifier_to_use << " " << name << ";\n";
+        }
+        ss << "};\n";
       }
-      ss << "};\n";
-    }
-    else
-    {
-      const char* qualifier = GetInterpolationQualifier(false, msaa, ssaa, false);
-
-      for (u32 i = 0; i < num_color_inputs; i++)
-        ss << qualifier << (noperspective_color ? "noperspective " : "") << "in float4 v_col" << i << ";\n";
-
-      for (u32 i = 0; i < num_texcoord_inputs; i++)
-        ss << qualifier << "in float2 v_tex" << i << ";\n";
-
-      for (const auto& [qualifiers, name] : additional_inputs)
+      else
       {
-        const char* qualifier_to_use = (std::strlen(qualifiers) > 0) ? qualifiers : qualifier;
-        ss << qualifier_to_use << " in " << name << ";\n";
+        const char* qualifier = GetInterpolationQualifier(false, msaa, ssaa, false);
+
+        for (u32 i = 0; i < num_color_inputs; i++)
+          ss << qualifier << (noperspective_color ? "noperspective " : "") << "in float4 v_col" << i << ";\n";
+
+        for (u32 i = 0; i < num_texcoord_inputs; i++)
+          ss << qualifier << "in float2 v_tex" << i << ";\n";
+
+        for (const auto& [qualifiers, name] : additional_inputs)
+        {
+          const char* qualifier_to_use = (std::strlen(qualifiers) > 0) ? qualifiers : qualifier;
+          ss << qualifier_to_use << " in " << name << ";\n";
+        }
       }
     }