diff --git a/bin/resources/shaders/opengl/tfx_vgs.glsl b/bin/resources/shaders/opengl/tfx_vgs.glsl
index c5f6e2c012..c3dcd7489b 100644
--- a/bin/resources/shaders/opengl/tfx_vgs.glsl
+++ b/bin/resources/shaders/opengl/tfx_vgs.glsl
@@ -67,6 +67,10 @@ void vs_main()
 
     VSout.c = i_c;
     VSout.t_float.z = i_f.x; // pack for with texture
+
+    #if VS_POINT_SIZE
+        gl_PointSize = float(VS_POINT_SIZE_VALUE);
+    #endif
 }
 
 #endif
diff --git a/pcsx2/GS/Renderers/Common/GSDevice.h b/pcsx2/GS/Renderers/Common/GSDevice.h
index c2e749c6bb..c04adf9187 100644
--- a/pcsx2/GS/Renderers/Common/GSDevice.h
+++ b/pcsx2/GS/Renderers/Common/GSDevice.h
@@ -165,6 +165,7 @@ struct alignas(16) GSHWDrawConfig
 				u8 fst : 1;
 				u8 tme : 1;
 				u8 iip : 1;
+				u8 point_size : 1;		///< Set when points need to be expanded without geometry shader.
 				u8 _free : 1;
 			};
 			u8 key;
@@ -471,7 +472,8 @@ struct alignas(16) GSHWDrawConfig
 	bool require_full_barrier; ///< Require texture barrier between all prims
 
 	DestinationAlphaMode destination_alpha;
-	bool datm;
+	bool datm : 1;
+	bool line_expand : 1;
 
 	struct AlphaSecondPass
 	{
@@ -496,6 +498,8 @@ public:
 		bool image_load_store     : 1; ///< Supports atomic min and max on images (for use with prim tracking destination alpha algorithm)
 		bool texture_barrier      : 1; ///< Supports sampling rt and hopefully texture barrier
 		bool provoking_vertex_last: 1; ///< Supports using the last vertex in a primitive as the value for flat shading.
+		bool point_expand         : 1; ///< Supports point expansion in hardware without using geometry shaders.
+		bool line_expand          : 1; ///< Supports line expansion in hardware without using geometry shaders.
 		FeatureSupport()
 		{
 			memset(this, 0, sizeof(*this));
diff --git a/pcsx2/GS/Renderers/DX11/GSDevice11.cpp b/pcsx2/GS/Renderers/DX11/GSDevice11.cpp
index 81bae29849..74d1fb69a3 100644
--- a/pcsx2/GS/Renderers/DX11/GSDevice11.cpp
+++ b/pcsx2/GS/Renderers/DX11/GSDevice11.cpp
@@ -40,6 +40,8 @@ GSDevice11::GSDevice11()
 	m_features.image_load_store = false;
 	m_features.texture_barrier = false;
 	m_features.provoking_vertex_last = false;
+	m_features.point_expand = false;
+	m_features.line_expand = false;
 }
 
 bool GSDevice11::SetFeatureLevel(D3D_FEATURE_LEVEL level, bool compat_mode)
diff --git a/pcsx2/GS/Renderers/HW/GSRendererNew.cpp b/pcsx2/GS/Renderers/HW/GSRendererNew.cpp
index e4b824cba4..6abd590655 100644
--- a/pcsx2/GS/Renderers/HW/GSRendererNew.cpp
+++ b/pcsx2/GS/Renderers/HW/GSRendererNew.cpp
@@ -55,15 +55,23 @@ void GSRendererNew::SetupIA(const float& sx, const float& sy)
 		for (unsigned int i = 0; i < m_vertex.next; i++)
 			m_vertex.buff[i].UV &= 0x3FEF3FEF;
 	}
-	const bool unscale_pt_ln = m_userHacks_enabled_unscale_ptln && (GetUpscaleMultiplier() != 1) && m_dev->Features().geometry_shader;
+	const bool unscale_pt_ln = m_userHacks_enabled_unscale_ptln && (GetUpscaleMultiplier() != 1);
+	const GSDevice::FeatureSupport features = m_dev->Features();
 
 	switch (m_vt.m_primclass)
 	{
 		case GS_POINT_CLASS:
 			if (unscale_pt_ln)
 			{
-				m_conf.gs.expand = true;
-				m_conf.cb_vs.point_size = GSVector2(16.0f * sx, 16.0f * sy);
+				if (features.point_expand)
+				{
+					m_conf.vs.point_size = true;
+				}
+				else if (features.geometry_shader)
+				{
+					m_conf.gs.expand = true;
+					m_conf.cb_vs.point_size = GSVector2(16.0f * sx, 16.0f * sy);
+				}
 			}
 
 			m_conf.gs.topology = GSHWDrawConfig::GSTopology::Point;
@@ -74,8 +82,15 @@ void GSRendererNew::SetupIA(const float& sx, const float& sy)
 		case GS_LINE_CLASS:
 			if (unscale_pt_ln)
 			{
-				m_conf.gs.expand = true;
-				m_conf.cb_vs.point_size = GSVector2(16.0f * sx, 16.0f * sy);
+				if (features.line_expand)
+				{
+					m_conf.line_expand = true;
+				}
+				else if (features.geometry_shader)
+				{
+					m_conf.gs.expand = true;
+					m_conf.cb_vs.point_size = GSVector2(16.0f * sx, 16.0f * sy);
+				}
 			}
 
 			m_conf.gs.topology = GSHWDrawConfig::GSTopology::Line;
diff --git a/pcsx2/GS/Renderers/OpenGL/GLState.cpp b/pcsx2/GS/Renderers/OpenGL/GLState.cpp
index 119cd8e840..2e6659590e 100644
--- a/pcsx2/GS/Renderers/OpenGL/GLState.cpp
+++ b/pcsx2/GS/Renderers/OpenGL/GLState.cpp
@@ -22,6 +22,9 @@ namespace GLState
 	GSVector2i viewport;
 	GSVector4i scissor;
 
+	bool point_size = false;
+	float line_width = 1.0f;
+
 	bool blend;
 	u16 eq_RGB;
 	u16 f_sRGB;
diff --git a/pcsx2/GS/Renderers/OpenGL/GLState.h b/pcsx2/GS/Renderers/OpenGL/GLState.h
index 25e7350179..e8a63606f9 100644
--- a/pcsx2/GS/Renderers/OpenGL/GLState.h
+++ b/pcsx2/GS/Renderers/OpenGL/GLState.h
@@ -24,6 +24,9 @@ namespace GLState
 	extern GSVector2i viewport;
 	extern GSVector4i scissor;
 
+	extern bool point_size;
+	extern float line_width;
+
 	extern bool blend;
 	extern u16 eq_RGB;
 	extern u16 f_sRGB;
diff --git a/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp b/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp
index b28eb40a71..a624f7ff31 100644
--- a/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp
+++ b/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp
@@ -256,6 +256,15 @@ bool GSDeviceOGL::Create(const WindowInfo& wi)
 	m_features.texture_barrier = true;
 	m_features.provoking_vertex_last = true;
 
+	GLint point_range[2] = {};
+	GLint line_range[2] = {};
+	glGetIntegerv(GL_ALIASED_POINT_SIZE_RANGE, point_range);
+	glGetIntegerv(GL_ALIASED_LINE_WIDTH_RANGE, line_range);
+	m_features.point_expand = (point_range[0] <= m_upscale_multiplier && point_range[1] >= m_upscale_multiplier);
+	m_features.line_expand = (line_range[0] <= m_upscale_multiplier && line_range[1] >= m_upscale_multiplier);
+	Console.WriteLn("Using %s for point expansion and %s for line expansion.",
+		m_features.point_expand ? "hardware" : "geometry shaders", m_features.line_expand ? "hardware" : "geometry shaders");
+
 	{
 		auto shader = Host::ReadResourceFileToString("shaders/opengl/common_header.glsl");
 		if (!shader.has_value())
@@ -991,7 +1000,10 @@ std::string GSDeviceOGL::GetVSSource(VSSelector sel)
 #endif
 
 	std::string macro = format("#define VS_INT_FST %d\n", sel.int_fst)
-		+ format("#define VS_IIP %d\n", sel.iip);
+		+ format("#define VS_IIP %d\n", sel.iip)
+		+ format("#define VS_POINT_SIZE %d\n", sel.point_size);
+	if (sel.point_size)
+		macro += format("#define VS_POINT_SIZE_VALUE %d\n", m_upscale_multiplier);
 
 	std::string src = GenGlslHeader("vs_main", GL_VERTEX_SHADER, macro);
 	src += m_shader_common_header;
@@ -1784,6 +1796,7 @@ static GSDeviceOGL::VSSelector convertSel(const GSHWDrawConfig::VSSelector sel)
 	GSDeviceOGL::VSSelector out;
 	out.int_fst = !sel.fst;
 	out.iip = sel.iip;
+	out.point_size = sel.point_size;
 	return out;
 }
 
@@ -1892,6 +1905,23 @@ void GSDeviceOGL::RenderHW(GSHWDrawConfig& config)
 
 	SetupPipeline(psel);
 
+	// additional non-pipeline config stuff
+	const bool point_size_enabled = config.vs.point_size;
+	if (GLState::point_size != point_size_enabled)
+	{
+		if (point_size_enabled)
+			glEnable(GL_PROGRAM_POINT_SIZE);
+		else
+			glDisable(GL_PROGRAM_POINT_SIZE);
+		GLState::point_size = point_size_enabled;
+	}
+	const float line_width = config.line_expand ? static_cast<float>(m_upscale_multiplier) : 1.0f;
+	if (GLState::line_width != line_width)
+	{
+		GLState::line_width = line_width;
+		glLineWidth(line_width);
+	}
+
 	if (config.destination_alpha == GSHWDrawConfig::DestinationAlphaMode::PrimIDTracking)
 	{
 		GL_PUSH("Date GL42");
diff --git a/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.h b/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.h
index cd04b3fe6f..8e844797d2 100644
--- a/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.h
+++ b/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.h
@@ -133,7 +133,8 @@ public:
 			{
 				u32 int_fst : 1;
 				u32 iip : 1;
-				u32 _free : 30;
+				u32 point_size : 1;
+				u32 _free : 29;
 			};
 
 			u32 key;