diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 898fcc78948ca85f6f89254718f1c157c218e024..0f7e9c479a7412282ffaf3df897f8a2d2dfe738b 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -225,6 +225,7 @@ target_compile_definitions(SRB2SDL2 PRIVATE -DHAVE_DISCORDRPC -DUSE_STUN)
 target_sources(SRB2SDL2 PRIVATE discord.c stun.c)
 
 target_link_libraries(SRB2SDL2 PRIVATE tcbrindle::span)
+target_link_libraries(SRB2SDL2 PRIVATE stb_rect_pack)
 target_link_libraries(SRB2SDL2 PRIVATE stb_vorbis)
 target_link_libraries(SRB2SDL2 PRIVATE xmp-lite::xmp-lite)
 target_link_libraries(SRB2SDL2 PRIVATE glad::glad)
diff --git a/src/cxxutil.hpp b/src/cxxutil.hpp
index 06f6f1adc25e8916f0511bce4d93f30e12d5d452..56b85c79b476d469746c46e5f3419556b0d6dd60 100644
--- a/src/cxxutil.hpp
+++ b/src/cxxutil.hpp
@@ -162,6 +162,17 @@ struct Overload : Ts... {
 template <typename... Ts>
 Overload(Ts...) -> Overload<Ts...>;
 
+inline void hash_combine(std::size_t& seed)
+{}
+
+template <class T, typename... Rest>
+inline void hash_combine(std::size_t& seed, const T& v, Rest... rest)
+{
+	std::hash<T> hasher;
+	seed ^= hasher(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
+	hash_combine(seed, std::forward<Rest>(rest)...);
+}
+
 } // namespace srb2
 
 #endif // __SRB2_CXXUTIL_HPP__
diff --git a/src/f_finale.h b/src/f_finale.h
index ca110821431c66d6a839e4a44d79946b077a5993..e2d4599a262318942c56a12a1a7957bf39da9bb8 100644
--- a/src/f_finale.h
+++ b/src/f_finale.h
@@ -140,6 +140,10 @@ extern UINT16 curtttics;
 //
 
 extern boolean WipeInAction;
+extern UINT8 g_wipetype;
+extern UINT8 g_wipeframe;
+extern boolean g_wipereverse;
+extern boolean g_wipeskiprender;
 extern boolean WipeStageTitle;
 
 extern INT32 lastwipetic;
diff --git a/src/f_wipe.c b/src/f_wipe.c
index b15c4b171ee0ece7a7f2f10d17b4017000fcca0b..98097b32c201e9162dde6f5b78e23d9fb3289849 100644
--- a/src/f_wipe.c
+++ b/src/f_wipe.c
@@ -90,6 +90,10 @@ UINT8 wipedefs[NUMWIPEDEFS] = {
 //--------------------------------------------------------------------------
 
 boolean WipeInAction = false;
+UINT8 g_wipetype = 0;
+UINT8 g_wipeframe = 0;
+boolean g_wipereverse = false;
+boolean g_wipeskiprender = false;
 boolean WipeStageTitle = false;
 INT32 lastwipetic = 0;
 
@@ -189,152 +193,6 @@ static fademask_t *F_GetFadeMask(UINT8 masknum, UINT8 scrnnum) {
 	return NULL;
 }
 
-/**	Wipe ticker
-  *
-  * \param	fademask	pixels to change
-  */
-static void F_DoWipe(fademask_t *fademask, lighttable_t *fadecolormap, boolean reverse)
-{
-	// Software mask wipe -- optimized; though it might not look like it!
-	// Okay, to save you wondering *how* this is more optimized than the simpler
-	// version that came before it...
-	// ---
-	// The previous code did two FixedMul calls for every single pixel on the
-	// screen, of which there are hundreds of thousands -- if not millions -- of.
-	// This worked fine for smaller screen sizes, but with excessively large
-	// (1920x1200) screens that meant 4 million+ calls out to FixedMul, and that
-	// would take /just/ long enough that fades would start to noticably lag.
-	// ---
-	// This code iterates over the fade mask's pixels instead of the screen's,
-	// and deals with drawing over each rectangular area before it moves on to
-	// the next pixel in the fade mask.  As a result, it's more complex (and might
-	// look a little messy; sorry!) but it simultaneously runs at twice the speed.
-	// In addition, we precalculate all the X and Y positions that we need to draw
-	// from and to, so it uses a little extra memory, but again, helps it run faster.
-	// ---
-	// Sal: I kinda destroyed some of this code by introducing Genesis-style fades.
-	// A colormap can be provided in F_RunWipe, which the white/black values will be
-	// remapped to the appropriate entry in the fade colormap.
-	{
-		// wipe screen, start, end
-		UINT8       *w = wipe_scr;
-		const UINT8 *s = wipe_scr_start;
-		const UINT8 *e = wipe_scr_end;
-
-		// first pixel for each screen
-		UINT8       *w_base = w;
-		const UINT8 *s_base = s;
-		const UINT8 *e_base = e;
-
-		// mask data, end
-		UINT8       *transtbl;
-		const UINT8 *mask    = fademask->mask;
-		const UINT8 *maskend = mask + fademask->size;
-
-		// rectangle draw hints
-		UINT32 draw_linestart, draw_rowstart;
-		UINT32 draw_lineend,   draw_rowend;
-		UINT32 draw_linestogo, draw_rowstogo;
-
-		// rectangle coordinates, etc.
-		UINT16* scrxpos = (UINT16*)malloc((fademask->width + 1)  * sizeof(UINT16));
-		UINT16* scrypos = (UINT16*)malloc((fademask->height + 1) * sizeof(UINT16));
-		UINT16 maskx, masky;
-		UINT32 relativepos;
-
-		// ---
-		// Screw it, we do the fixed point math ourselves up front.
-		scrxpos[0] = 0;
-		for (relativepos = 0, maskx = 1; maskx < fademask->width; ++maskx)
-			scrxpos[maskx] = (relativepos += fademask->xscale)>>FRACBITS;
-		scrxpos[fademask->width] = vid.width;
-
-		scrypos[0] = 0;
-		for (relativepos = 0, masky = 1; masky < fademask->height; ++masky)
-			scrypos[masky] = (relativepos += fademask->yscale)>>FRACBITS;
-		scrypos[fademask->height] = vid.height;
-		// ---
-
-		maskx = masky = 0;
-		do
-		{
-			UINT8 m = *mask;
-
-			draw_rowstart = scrxpos[maskx];
-			draw_rowend   = scrxpos[maskx + 1];
-			draw_linestart = scrypos[masky];
-			draw_lineend   = scrypos[masky + 1];
-
-			relativepos = (draw_linestart * vid.width) + draw_rowstart;
-			draw_linestogo = draw_lineend - draw_linestart;
-
-			if (reverse)
-				m = ((pallen-1) - m);
-
-			if (m == 0)
-			{
-				// shortcut - memcpy source to work
-				while (draw_linestogo--)
-				{
-					M_Memcpy(w_base+relativepos, (reverse ? e_base : s_base)+relativepos, draw_rowend-draw_rowstart);
-					relativepos += vid.width;
-				}
-			}
-			else if (m >= (pallen-1))
-			{
-				// shortcut - memcpy target to work
-				while (draw_linestogo--)
-				{
-					M_Memcpy(w_base+relativepos, (reverse ? s_base : e_base)+relativepos, draw_rowend-draw_rowstart);
-					relativepos += vid.width;
-				}
-			}
-			else
-			{
-				// pointer to transtable that this mask would use
-				transtbl = transtables + ((9 - m)<<FF_TRANSSHIFT);
-
-				// DRAWING LOOP
-				while (draw_linestogo--)
-				{
-					w = w_base + relativepos;
-					s = s_base + relativepos;
-					e = e_base + relativepos;
-					draw_rowstogo = draw_rowend - draw_rowstart;
-
-					if (fadecolormap)
-					{
-						if (reverse)
-							s = e;
-						while (draw_rowstogo--)
-							*w++ = fadecolormap[ ( m << 8 ) + *s++ ];
-					}
-					else while (draw_rowstogo--)
-					{
-						/*if (fadecolormap != NULL)
-						{
-							if (reverse)
-								*w++ = fadecolormap[ ( m << 8 ) + *e++ ];
-							else
-								*w++ = fadecolormap[ ( m << 8 ) + *s++ ];
-						}
-						else*/
-							*w++ = transtbl[ ( *e++ << 8 ) + *s++ ];
-					}
-
-					relativepos += vid.width;
-				}
-				// END DRAWING LOOP
-			}
-
-			if (++maskx >= fademask->width)
-				++masky, maskx = 0;
-		} while (++mask < maskend);
-
-		free(scrxpos);
-		free(scrypos);
-	}
-}
 #endif
 
 /** Save the "before" screen of a wipe.
@@ -467,6 +325,7 @@ void F_RunWipe(UINT8 wipetype, boolean drawMenu, const char *colormap, boolean r
 
 	// Init the wipe
 	WipeInAction = true;
+	g_wipeskiprender = false;
 	wipe_scr = screens[0];
 
 	// lastwipetic should either be 0 or the tic we last wiped
@@ -494,7 +353,10 @@ void F_RunWipe(UINT8 wipetype, boolean drawMenu, const char *colormap, boolean r
 
 		if (rendermode != render_none) //this allows F_RunWipe to be called in dedicated servers
 		{
-			F_DoWipe(fmask, fcolor, reverse);
+			// F_DoWipe(fmask, fcolor, reverse);
+			g_wipetype = wipetype;
+			g_wipeframe = wipeframe - 1;
+			g_wipereverse = reverse;
 
 			if (encorewiggle)
 			{
@@ -521,6 +383,12 @@ void F_RunWipe(UINT8 wipetype, boolean drawMenu, const char *colormap, boolean r
 
 		I_FinishUpdate(); // page flip or blit buffer
 
+		if (rendermode != render_none)
+		{
+			// Skip subsequent renders until the end of the wipe to preserve the current frame.
+			g_wipeskiprender = true;
+		}
+
 		if (moviemode)
 			M_SaveFrame();
 
@@ -528,6 +396,7 @@ void F_RunWipe(UINT8 wipetype, boolean drawMenu, const char *colormap, boolean r
 	}
 
 	WipeInAction = false;
+	g_wipeskiprender = false;
 
 	if (fcolor)
 	{
diff --git a/src/hwr2/CMakeLists.txt b/src/hwr2/CMakeLists.txt
index 50481810520ac9e551709d52c213566ca7b9d6d4..34aa2186fb945c7232800cd29d82cd3b4320d086 100644
--- a/src/hwr2/CMakeLists.txt
+++ b/src/hwr2/CMakeLists.txt
@@ -1,8 +1,20 @@
 target_sources(SRB2SDL2 PRIVATE
+	pass_blit_rect.cpp
+	pass_blit_rect.hpp
 	pass_imgui.cpp
 	pass_imgui.hpp
+	pass_manager.cpp
+	pass_manager.hpp
+	pass_postprocess.cpp
+	pass_postprocess.hpp
+	pass_resource_managers.cpp
+	pass_resource_managers.hpp
 	pass_software.cpp
 	pass_software.hpp
+	pass_twodee.cpp
+	pass_twodee.hpp
 	pass.cpp
 	pass.hpp
+	twodee.cpp
+	twodee.hpp
 )
diff --git a/src/hwr2/pass.cpp b/src/hwr2/pass.cpp
index 48b331492882cb49dbce16133c6a0fdeb35c5924..d20be7294f4accf537dee33bb21e83e3526291d5 100644
--- a/src/hwr2/pass.cpp
+++ b/src/hwr2/pass.cpp
@@ -1,3 +1,15 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "pass.hpp"
 
-srb2::hwr2::Pass::~Pass() = default;
+using namespace srb2;
+using namespace srb2::hwr2;
+
+Pass::~Pass() = default;
diff --git a/src/hwr2/pass.hpp b/src/hwr2/pass.hpp
index 2556bf8f855b7740d299102f541562520291c030..a745bd12bf2dd2d75808e70fcc179b12cefd191b 100644
--- a/src/hwr2/pass.hpp
+++ b/src/hwr2/pass.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_HWR2_PASS_HPP__
 #define __SRB2_HWR2_PASS_HPP__
 
@@ -8,7 +17,9 @@ namespace srb2::hwr2
 
 /// @brief A rendering pass which performs logic during each phase of a frame render.
 /// During rendering, all registered Pass's individual stages will be run together.
-struct Pass {
+class Pass
+{
+public:
 	virtual ~Pass();
 
 	/// @brief Perform rendering logic and create necessary GPU resources.
diff --git a/src/hwr2/pass_blit_rect.cpp b/src/hwr2/pass_blit_rect.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..56fb4f6dce900109aeb3ba323fcbc8c45b6a333d
--- /dev/null
+++ b/src/hwr2/pass_blit_rect.cpp
@@ -0,0 +1,209 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#include "pass_blit_rect.hpp"
+
+#include <optional>
+
+#include <tcb/span.hpp>
+
+#include "../cxxutil.hpp"
+
+using namespace srb2;
+using namespace srb2::hwr2;
+using namespace srb2::rhi;
+
+namespace
+{
+struct BlitVertex
+{
+	float x = 0.f;
+	float y = 0.f;
+	float z = 0.f;
+	float u = 0.f;
+	float v = 0.f;
+};
+} // namespace
+
+static const BlitVertex kVerts[] =
+	{{-.5f, -.5f, 0.f, 0.f, 0.f}, {.5f, -.5f, 0.f, 1.f, 0.f}, {-.5f, .5f, 0.f, 0.f, 1.f}, {.5f, .5f, 0.f, 1.f, 1.f}};
+
+static const uint16_t kIndices[] = {0, 1, 2, 1, 3, 2};
+
+/// @brief Pipeline used for paletted source textures. Requires the texture and the palette texture.
+static const PipelineDesc kPalettedPipelineDescription = {
+	PipelineProgram::kUnshadedPaletted,
+	{{{sizeof(BlitVertex)}}, {{VertexAttributeName::kPosition, 0, 0}, {VertexAttributeName::kTexCoord0, 0, 12}}},
+	{{{{UniformName::kProjection}}, {{UniformName::kModelView, UniformName::kTexCoord0Transform}}}},
+	{{// R8 index texture
+	  SamplerName::kSampler0,
+	  // 256x1 palette texture
+	  SamplerName::kSampler1}},
+	std::nullopt,
+	{PixelFormat::kRGBA8, std::nullopt, {true, true, true, true}},
+	PrimitiveType::kTriangles,
+	CullMode::kNone,
+	FaceWinding::kCounterClockwise,
+	{0.f, 0.f, 0.f, 1.f}};
+
+/// @brief Pipeline used for non-paletted source textures.
+static const PipelineDesc kUnshadedPipelineDescription = {
+	PipelineProgram::kUnshaded,
+	{{{sizeof(BlitVertex)}}, {{VertexAttributeName::kPosition, 0, 0}, {VertexAttributeName::kTexCoord0, 0, 12}}},
+	{{{{UniformName::kProjection}}, {{UniformName::kModelView, UniformName::kTexCoord0Transform}}}},
+	{{// RGB/A texture
+	  SamplerName::kSampler0}},
+	std::nullopt,
+	{PixelFormat::kRGBA8, std::nullopt, {true, true, true, true}},
+	PrimitiveType::kTriangles,
+	CullMode::kNone,
+	FaceWinding::kCounterClockwise,
+	{0.f, 0.f, 0.f, 1.f}};
+
+BlitRectPass::BlitRectPass() : Pass()
+{
+}
+
+BlitRectPass::BlitRectPass(bool output_clear) : Pass(), output_clear_(output_clear)
+{
+}
+
+BlitRectPass::BlitRectPass(const std::shared_ptr<MainPaletteManager>& palette_mgr, bool output_clear)
+	: Pass(), output_clear_(output_clear), palette_mgr_(palette_mgr)
+{
+}
+
+BlitRectPass::~BlitRectPass() = default;
+
+void BlitRectPass::prepass(Rhi& rhi)
+{
+	if (!pipeline_)
+	{
+		if (palette_mgr_)
+		{
+			pipeline_ = rhi.create_pipeline(kPalettedPipelineDescription);
+		}
+		else
+		{
+			pipeline_ = rhi.create_pipeline(kUnshadedPipelineDescription);
+		}
+	}
+
+	if (!quad_vbo_)
+	{
+		quad_vbo_ = rhi.create_buffer({sizeof(kVerts), BufferType::kVertexBuffer, BufferUsage::kImmutable});
+		quad_vbo_needs_upload_ = true;
+	}
+
+	if (!quad_ibo_)
+	{
+		quad_ibo_ = rhi.create_buffer({sizeof(kIndices), BufferType::kIndexBuffer, BufferUsage::kImmutable});
+		quad_ibo_needs_upload_ = true;
+	}
+
+	if (!render_pass_)
+	{
+		render_pass_ = rhi.create_render_pass(
+			{std::nullopt,
+			 PixelFormat::kRGBA8,
+			 output_clear_ ? AttachmentLoadOp::kClear : AttachmentLoadOp::kLoad,
+			 AttachmentStoreOp::kStore}
+		);
+	}
+}
+
+void BlitRectPass::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+{
+	if (quad_vbo_needs_upload_ && quad_vbo_)
+	{
+		rhi.update_buffer_contents(ctx, quad_vbo_, 0, tcb::as_bytes(tcb::span(kVerts)));
+		quad_vbo_needs_upload_ = false;
+	}
+
+	if (quad_ibo_needs_upload_ && quad_ibo_)
+	{
+		rhi.update_buffer_contents(ctx, quad_ibo_, 0, tcb::as_bytes(tcb::span(kIndices)));
+		quad_ibo_needs_upload_ = false;
+	}
+
+	float aspect = 1.0;
+	float output_aspect = 1.0;
+	if (output_correct_aspect_)
+	{
+		aspect = static_cast<float>(texture_width_) / static_cast<float>(texture_height_);
+		output_aspect = static_cast<float>(output_width_) / static_cast<float>(output_height_);
+	}
+	bool taller = aspect > output_aspect;
+
+	std::array<rhi::UniformVariant, 1> g1_uniforms = {{
+		// Projection
+		std::array<std::array<float, 4>, 4> {
+			{{taller ? 1.f : 1.f / output_aspect, 0.f, 0.f, 0.f},
+			 {0.f, taller ? -1.f / (1.f / output_aspect) : -1.f, 0.f, 0.f},
+			 {0.f, 0.f, 1.f, 0.f},
+			 {0.f, 0.f, 0.f, 1.f}}},
+	}};
+
+	std::array<rhi::UniformVariant, 2> g2_uniforms = {
+		{// ModelView
+		 std::array<std::array<float, 4>, 4> {
+			 {{taller ? 2.f : 2.f * aspect, 0.f, 0.f, 0.f},
+			  {0.f, taller ? 2.f * (1.f / aspect) : 2.f, 0.f, 0.f},
+			  {0.f, 0.f, 1.f, 0.f},
+			  {0.f, 0.f, 0.f, 1.f}}},
+		 // Texcoord0 Transform
+		 std::array<std::array<float, 3>, 3> {
+			 {{1.f, 0.f, 0.f}, {0.f, output_flip_ ? -1.f : 1.f, 0.f}, {0.f, 0.f, 1.f}}}}};
+
+	uniform_sets_[0] = rhi.create_uniform_set(ctx, {g1_uniforms});
+	uniform_sets_[1] = rhi.create_uniform_set(ctx, {g2_uniforms});
+
+	std::array<rhi::VertexAttributeBufferBinding, 1> vbs = {{{0, quad_vbo_}}};
+	if (palette_mgr_)
+	{
+		std::array<rhi::TextureBinding, 2> tbs = {
+			{{rhi::SamplerName::kSampler0, texture_}, {rhi::SamplerName::kSampler1, palette_mgr_->palette()}}};
+		binding_set_ = rhi.create_binding_set(ctx, pipeline_, {vbs, tbs});
+	}
+	else
+	{
+		std::array<rhi::TextureBinding, 1> tbs = {{{rhi::SamplerName::kSampler0, texture_}}};
+		binding_set_ = rhi.create_binding_set(ctx, pipeline_, {vbs, tbs});
+	}
+}
+
+static constexpr const rhi::Color kClearColor = {0, 0, 0, 1};
+
+void BlitRectPass::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+{
+	if (output_)
+	{
+		rhi.begin_render_pass(ctx, {render_pass_, output_, std::nullopt, kClearColor});
+	}
+	else
+	{
+		rhi.begin_default_render_pass(ctx, output_clear_);
+	}
+
+	rhi.bind_pipeline(ctx, pipeline_);
+	if (output_)
+	{
+		rhi.set_viewport(ctx, {0, 0, output_width_, output_height_});
+	}
+	rhi.bind_uniform_set(ctx, 0, uniform_sets_[0]);
+	rhi.bind_uniform_set(ctx, 1, uniform_sets_[1]);
+	rhi.bind_binding_set(ctx, binding_set_);
+	rhi.bind_index_buffer(ctx, quad_ibo_);
+	rhi.draw_indexed(ctx, 6, 0);
+	rhi.end_render_pass(ctx);
+}
+
+void BlitRectPass::postpass(Rhi& rhi)
+{
+}
diff --git a/src/hwr2/pass_blit_rect.hpp b/src/hwr2/pass_blit_rect.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..d828812cd78c346105f54c78bab673a932c583a4
--- /dev/null
+++ b/src/hwr2/pass_blit_rect.hpp
@@ -0,0 +1,91 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#ifndef __SRB2_HWR2_PASS_BLIT_RECT_HPP__
+#define __SRB2_HWR2_PASS_BLIT_RECT_HPP__
+
+#include <array>
+
+#include "../rhi/rhi.hpp"
+#include "pass.hpp"
+#include "pass_resource_managers.hpp"
+
+namespace srb2::hwr2
+{
+
+/// @brief A render pass which blits a rect using a source texture or textures.
+class BlitRectPass final : public Pass
+{
+	rhi::Handle<rhi::Pipeline> pipeline_;
+	rhi::Handle<rhi::Texture> texture_;
+	uint32_t texture_width_ = 0;
+	uint32_t texture_height_ = 0;
+	rhi::Handle<rhi::Texture> output_;
+	uint32_t output_width_ = 0;
+	uint32_t output_height_ = 0;
+	bool output_correct_aspect_ = false;
+	bool output_clear_ = true;
+	bool output_flip_ = false;
+	rhi::Handle<rhi::RenderPass> render_pass_;
+	rhi::Handle<rhi::Buffer> quad_vbo_;
+	rhi::Handle<rhi::Buffer> quad_ibo_;
+	std::array<rhi::Handle<rhi::UniformSet>, 2> uniform_sets_;
+	rhi::Handle<rhi::BindingSet> binding_set_;
+
+	bool quad_vbo_needs_upload_ = false;
+	bool quad_ibo_needs_upload_ = false;
+
+	// The presence of a palette manager indicates that the source texture will be paletted. This can't be changed.
+	std::shared_ptr<MainPaletteManager> palette_mgr_;
+
+public:
+	BlitRectPass();
+	BlitRectPass(bool output_clear);
+	BlitRectPass(const std::shared_ptr<MainPaletteManager>& palette_mgr, bool output_clear);
+	virtual ~BlitRectPass();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+	virtual void postpass(rhi::Rhi& rhi) override;
+
+	/// @brief Set the next blit texture. Don't call during graphics phase!
+	/// @param texture the texture to use when blitting
+	/// @param width   texture width
+	/// @param height  texture height
+	void set_texture(rhi::Handle<rhi::Texture> texture, uint32_t width, uint32_t height) noexcept
+	{
+		texture_ = texture;
+		texture_width_ = width;
+		texture_height_ = height;
+	}
+
+	/// @brief Set the next output texture. Don't call during graphics phase!
+	/// @param texture the texture to use as a color buffer
+	/// @param width   texture width
+	/// @param height  texture height
+	void set_output(
+		rhi::Handle<rhi::Texture> color,
+		uint32_t width,
+		uint32_t height,
+		bool correct_aspect,
+		bool flip
+	) noexcept
+	{
+		output_ = color;
+		output_width_ = width;
+		output_height_ = height;
+		output_correct_aspect_ = correct_aspect;
+		output_flip_ = flip;
+	}
+};
+
+} // namespace srb2::hwr2
+
+#endif // __SRB2_HWR2_PASS_SOFTWARE_HPP__
diff --git a/src/hwr2/pass_imgui.cpp b/src/hwr2/pass_imgui.cpp
index 6d8e028618011089bade0d79dcdd1f636975168b..e0d1d3cb6e00f715ce5aaaaeb2844c77c9b8aeff 100644
--- a/src/hwr2/pass_imgui.cpp
+++ b/src/hwr2/pass_imgui.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "pass_imgui.hpp"
 
 #include <imgui.h>
@@ -8,48 +17,32 @@ using namespace srb2;
 using namespace srb2::hwr2;
 using namespace srb2::rhi;
 
-static const PipelineDesc kPipelineDesc =
-{
+static const PipelineDesc kPipelineDesc = {
 	PipelineProgram::kUnshaded,
-	{
-		{
-			{sizeof(ImDrawVert)}
-		},
-		{
-			{VertexAttributeName::kPosition, 0, 0},
-			{VertexAttributeName::kTexCoord0, 0, 12},
-			{VertexAttributeName::kColor, 0, 24}
-		}
-	},
-	{{
-		{{UniformName::kProjection}},
-		{{UniformName::kModelView, UniformName::kTexCoord0Transform}}
-	}},
-	{{
-		SamplerName::kSampler0
-	}},
-	PipelineDepthAttachmentDesc {
-		PixelFormat::kDepth16,
-		CompareFunc::kAlways,
-		true
-	},
-	{
-		PixelFormat::kRGBA8,
-		BlendDesc {
-			BlendFactor::kSourceAlpha,
-			BlendFactor::kOneMinusSourceAlpha,
-			BlendFunction::kAdd,
-			BlendFactor::kOne,
-			BlendFactor::kOneMinusSourceAlpha,
-			BlendFunction::kAdd
-		},
-		{true, true, true, true}
-	},
+	{{{sizeof(ImDrawVert)}},
+	 {{VertexAttributeName::kPosition, 0, 0},
+	  {VertexAttributeName::kTexCoord0, 0, 12},
+	  {VertexAttributeName::kColor, 0, 24}}},
+	{{{{UniformName::kProjection}}, {{UniformName::kModelView, UniformName::kTexCoord0Transform}}}},
+	{{SamplerName::kSampler0}},
+	PipelineDepthAttachmentDesc {PixelFormat::kDepth16, CompareFunc::kAlways, true},
+	{PixelFormat::kRGBA8,
+	 BlendDesc {
+		 BlendFactor::kSourceAlpha,
+		 BlendFactor::kOneMinusSourceAlpha,
+		 BlendFunction::kAdd,
+		 BlendFactor::kOne,
+		 BlendFactor::kOneMinusSourceAlpha,
+		 BlendFunction::kAdd},
+	 {true, true, true, true}},
 	PrimitiveType::kTriangles,
 	CullMode::kNone,
 	FaceWinding::kCounterClockwise,
-	{0.f, 0.f, 0.f, 1.f}
-};
+	{0.f, 0.f, 0.f, 1.f}};
+
+ImguiPass::ImguiPass() : Pass()
+{
+}
 
 ImguiPass::~ImguiPass() = default;
 
@@ -86,18 +79,10 @@ void ImguiPass::prepass(Rhi& rhi)
 	for (auto list : draw_lists)
 	{
 		Handle<Buffer> vbo = rhi.create_buffer(
-			{
-				static_cast<uint32_t>(list->VtxBuffer.size_in_bytes()),
-				BufferType::kVertexBuffer,
-				BufferUsage::kImmutable
-			}
+			{static_cast<uint32_t>(list->VtxBuffer.size_in_bytes()), BufferType::kVertexBuffer, BufferUsage::kImmutable}
 		);
 		Handle<Buffer> ibo = rhi.create_buffer(
-			{
-				static_cast<uint32_t>(list->IdxBuffer.size_in_bytes()),
-				BufferType::kIndexBuffer,
-				BufferUsage::kImmutable
-			}
+			{static_cast<uint32_t>(list->IdxBuffer.size_in_bytes()), BufferType::kIndexBuffer, BufferUsage::kImmutable}
 		);
 
 		DrawList hwr2_list;
@@ -126,13 +111,11 @@ void ImguiPass::prepass(Rhi& rhi)
 			draw_cmd.v_offset = cmd.VtxOffset;
 			draw_cmd.i_offset = cmd.IdxOffset;
 			draw_cmd.elems = cmd.ElemCount;
-			draw_cmd.clip =
-			{
+			draw_cmd.clip = {
 				static_cast<int32_t>(clip_min.x),
 				static_cast<int32_t>((data->DisplaySize.y * data->FramebufferScale.y) - clip_max.y),
 				static_cast<uint32_t>(clip_max.x - clip_min.x),
-				static_cast<uint32_t>(clip_max.y - clip_min.y)
-			};
+				static_cast<uint32_t>(clip_max.y - clip_min.y)};
 			hwr2_list.cmds.push_back(std::move(draw_cmd));
 		}
 		draw_lists_.push_back(std::move(hwr2_list));
@@ -179,35 +162,20 @@ void ImguiPass::transfer(Rhi& rhi, Handle<TransferContext> ctx)
 		rhi.update_buffer_contents(ctx, ibo, 0, tcb::as_bytes(index_span));
 
 		// Uniform sets
-		std::array<UniformVariant, 1> g1_uniforms =
-		{{
+		std::array<UniformVariant, 1> g1_uniforms = {{
 			// Projection
-			std::array<std::array<float, 4>, 4>
-			{{
-				{2.f / vid.realwidth, 0.f, 0.f, 0.f},
-				{0.f, 2.f / vid.realheight, 0.f, 0.f},
-				{0.f, 0.f, 1.f, 0.f},
-				{-1.f, 1.f, 0.f, 1.f}
-			}},
-		}};
-		std::array<UniformVariant, 2> g2_uniforms =
-		{{
-			// ModelView
-			std::array<std::array<float, 4>, 4>
-			{{
-				{1.f, 0.f, 0.f, 0.f},
-				{0.f, -1.f, 0.f, 0.f},
-				{0.f, 0.f, 1.f, 0.f},
-				{0.f, 0, 0.f, 1.f}
-			}},
-			// Texcoord0 Transform
-			std::array<std::array<float, 3>, 3>
-			{{
-				{1.f, 0.f, 0.f},
-				{0.f, 1.f, 0.f},
-				{0.f, 0.f, 1.f}
-			}}
+			std::array<std::array<float, 4>, 4> {
+				{{2.f / vid.realwidth, 0.f, 0.f, 0.f},
+				 {0.f, 2.f / vid.realheight, 0.f, 0.f},
+				 {0.f, 0.f, 1.f, 0.f},
+				 {-1.f, 1.f, 0.f, 1.f}}},
 		}};
+		std::array<UniformVariant, 2> g2_uniforms = {
+			{// ModelView
+			 std::array<std::array<float, 4>, 4> {
+				 {{1.f, 0.f, 0.f, 0.f}, {0.f, -1.f, 0.f, 0.f}, {0.f, 0.f, 1.f, 0.f}, {0.f, 0, 0.f, 1.f}}},
+			 // Texcoord0 Transform
+			 std::array<std::array<float, 3>, 3> {{{1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}}}}};
 		Handle<UniformSet> us_1 = rhi.create_uniform_set(ctx, {g1_uniforms});
 		Handle<UniformSet> us_2 = rhi.create_uniform_set(ctx, {g2_uniforms});
 
diff --git a/src/hwr2/pass_imgui.hpp b/src/hwr2/pass_imgui.hpp
index 280e7fc9c08aa0e591e6fd69ace3ed0e19b30e00..91d2afe20192d88af9618f554dd41a85c24db3a0 100644
--- a/src/hwr2/pass_imgui.hpp
+++ b/src/hwr2/pass_imgui.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_HWR2_PASS_IMGUI_HPP__
 #define __SRB2_HWR2_PASS_IMGUI_HPP__
 
@@ -9,7 +18,7 @@
 namespace srb2::hwr2
 {
 
-class ImguiPass : public Pass
+class ImguiPass final : public Pass
 {
 	struct DrawCmd
 	{
@@ -36,6 +45,7 @@ class ImguiPass : public Pass
 	std::vector<DrawList> draw_lists_;
 
 public:
+	ImguiPass();
 	virtual ~ImguiPass();
 
 	virtual void prepass(rhi::Rhi& rhi) override;
diff --git a/src/hwr2/pass_manager.cpp b/src/hwr2/pass_manager.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e79ef272506d26c2a8eb60b755807c494d1f0012
--- /dev/null
+++ b/src/hwr2/pass_manager.cpp
@@ -0,0 +1,169 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#include "pass_manager.hpp"
+
+using namespace srb2;
+using namespace srb2::hwr2;
+using namespace srb2::rhi;
+
+namespace
+{
+
+class LambdaPass final : public Pass
+{
+	PassManager* mgr_;
+	std::function<void(PassManager&, rhi::Rhi&)> prepass_func_;
+	std::function<void(PassManager&, rhi::Rhi&)> postpass_func_;
+
+public:
+	LambdaPass(PassManager* mgr, std::function<void(PassManager&, rhi::Rhi&)> prepass_func);
+	LambdaPass(
+		PassManager* mgr,
+		std::function<void(PassManager&, rhi::Rhi&)> prepass_func,
+		std::function<void(PassManager&, rhi::Rhi&)> postpass_func
+	);
+	virtual ~LambdaPass();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+	virtual void postpass(rhi::Rhi& rhi) override;
+};
+
+} // namespace
+
+LambdaPass::LambdaPass(PassManager* mgr, std::function<void(PassManager&, rhi::Rhi&)> prepass_func)
+	: mgr_(mgr), prepass_func_(prepass_func)
+{
+}
+
+LambdaPass::LambdaPass(
+	PassManager* mgr,
+	std::function<void(PassManager&, rhi::Rhi&)> prepass_func,
+	std::function<void(PassManager&, rhi::Rhi&)> postpass_func
+)
+	: mgr_(mgr), prepass_func_(prepass_func), postpass_func_(postpass_func)
+{
+}
+
+LambdaPass::~LambdaPass() = default;
+
+void LambdaPass::prepass(Rhi& rhi)
+{
+	if (prepass_func_)
+	{
+		(prepass_func_)(*mgr_, rhi);
+	}
+}
+
+void LambdaPass::transfer(Rhi&, Handle<TransferContext>)
+{
+}
+
+void LambdaPass::graphics(Rhi&, Handle<GraphicsContext>)
+{
+}
+
+void LambdaPass::postpass(Rhi& rhi)
+{
+	if (postpass_func_)
+	{
+		(postpass_func_)(*mgr_, rhi);
+	}
+}
+
+PassManager::PassManager() = default;
+
+void PassManager::insert(const std::string& name, std::shared_ptr<Pass> pass)
+{
+	SRB2_ASSERT(pass_by_name_.find(name) == pass_by_name_.end());
+
+	std::size_t index = passes_.size();
+	passes_.push_back(PassManagerEntry {name, pass, true});
+	pass_by_name_.insert({name, index});
+}
+
+void PassManager::insert(const std::string& name, std::function<void(PassManager&, Rhi&)> prepass_func)
+{
+	insert(std::forward<const std::string>(name), std::make_shared<LambdaPass>(LambdaPass {this, prepass_func}));
+}
+
+void PassManager::insert(
+	const std::string& name,
+	std::function<void(PassManager&, Rhi&)> prepass_func,
+	std::function<void(PassManager&, Rhi&)> postpass_func
+)
+{
+	insert(
+		std::forward<const std::string>(name),
+		std::make_shared<LambdaPass>(LambdaPass {this, prepass_func, postpass_func})
+	);
+}
+
+void PassManager::set_pass_enabled(const std::string& name, bool enabled)
+{
+	SRB2_ASSERT(pass_by_name_.find(name) != pass_by_name_.end());
+
+	passes_[pass_by_name_[name]].enabled = enabled;
+}
+
+std::weak_ptr<Pass> PassManager::for_name(const std::string& name)
+{
+	auto itr = pass_by_name_.find(name);
+	if (itr == pass_by_name_.end())
+	{
+		return std::weak_ptr<Pass>();
+	}
+	return passes_[itr->second].pass;
+}
+
+void PassManager::render(Rhi& rhi) const
+{
+	if (passes_.empty())
+	{
+		return;
+	}
+
+	for (auto& pass : passes_)
+	{
+		if (pass.enabled)
+		{
+			pass.pass->prepass(rhi);
+		}
+	}
+
+	Handle<TransferContext> tc = rhi.begin_transfer();
+	for (auto& pass : passes_)
+	{
+		if (pass.enabled)
+		{
+			pass.pass->transfer(rhi, tc);
+		}
+	}
+	rhi.end_transfer(tc);
+
+	Handle<GraphicsContext> gc = rhi.begin_graphics();
+	for (auto& pass : passes_)
+	{
+		if (pass.enabled)
+		{
+			pass.pass->graphics(rhi, gc);
+		}
+	}
+	rhi.end_graphics(gc);
+
+	for (auto& pass : passes_)
+	{
+		if (pass.enabled)
+		{
+			pass.pass->postpass(rhi);
+		}
+	}
+}
diff --git a/src/hwr2/pass_manager.hpp b/src/hwr2/pass_manager.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..372db893ec3e90b655484485dab550b36bb0f88b
--- /dev/null
+++ b/src/hwr2/pass_manager.hpp
@@ -0,0 +1,60 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#ifndef __SRB2_HWR2_PASS_MANAGER_HPP__
+#define __SRB2_HWR2_PASS_MANAGER_HPP__
+
+#include <cstddef>
+#include <functional>
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "../rhi/rhi.hpp"
+#include "pass.hpp"
+
+namespace srb2::hwr2
+{
+
+class PassManager
+{
+	struct PassManagerEntry
+	{
+		std::string name;
+		std::shared_ptr<Pass> pass;
+		bool enabled;
+	};
+
+	std::unordered_map<std::string, std::size_t> pass_by_name_;
+	std::vector<PassManagerEntry> passes_;
+
+public:
+	PassManager();
+	PassManager(const PassManager&) = delete;
+	PassManager(PassManager&&) = delete;
+	PassManager& operator=(const PassManager&) = delete;
+	PassManager& operator=(PassManager&&) = delete;
+
+	void insert(const std::string& name, std::shared_ptr<Pass> pass);
+	void insert(const std::string& name, std::function<void(PassManager&, rhi::Rhi&)> prepass_func);
+	void insert(
+		const std::string& name,
+		std::function<void(PassManager&, rhi::Rhi&)> prepass_func,
+		std::function<void(PassManager&, rhi::Rhi&)> postpass_func
+	);
+	std::weak_ptr<Pass> for_name(const std::string& name);
+	void set_pass_enabled(const std::string& name, bool enabled);
+
+	void render(rhi::Rhi& rhi) const;
+};
+
+} // namespace srb2::hwr2
+
+#endif // __SRB2_HWR2_PASS_MANAGER_HPP__
diff --git a/src/hwr2/pass_postprocess.cpp b/src/hwr2/pass_postprocess.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..89b1ae0051ef627473ac1082aa9a9cad47be0635
--- /dev/null
+++ b/src/hwr2/pass_postprocess.cpp
@@ -0,0 +1,217 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#include "pass_postprocess.hpp"
+
+#include <string>
+
+#include <fmt/format.h>
+#include <tcb/span.hpp>
+
+#include "../f_finale.h"
+#include "../w_wad.h"
+
+using namespace srb2;
+using namespace srb2::hwr2;
+using namespace srb2::rhi;
+
+namespace
+{
+struct PostprocessVertex
+{
+	float x;
+	float y;
+	float z;
+	float u;
+	float v;
+};
+
+static const PostprocessVertex kPostprocessVerts[] =
+	{{-.5f, -.5f, 0.f, 0.f, 0.f}, {.5f, -.5f, 0.f, 1.f, 0.f}, {-.5f, .5f, 0.f, 0.f, 1.f}, {.5f, .5f, 0.f, 1.f, 1.f}};
+
+static const uint16_t kPostprocessIndices[] = {0, 1, 2, 1, 3, 2};
+
+} // namespace
+
+static const PipelineDesc kWipePipelineDesc = {
+	PipelineProgram::kPostprocessWipe,
+	{{{sizeof(PostprocessVertex)}},
+	 {
+		 {VertexAttributeName::kPosition, 0, 0},
+		 {VertexAttributeName::kTexCoord0, 0, 12},
+	 }},
+	{{{{UniformName::kProjection}}}},
+	{{SamplerName::kSampler0, SamplerName::kSampler1}},
+	std::nullopt,
+	{PixelFormat::kRGBA8, std::nullopt, {true, true, true, true}},
+	PrimitiveType::kTriangles,
+	CullMode::kNone,
+	FaceWinding::kCounterClockwise,
+	{0.f, 0.f, 0.f, 1.f}};
+
+PostprocessWipePass::PostprocessWipePass() : Pass()
+{
+}
+
+PostprocessWipePass::~PostprocessWipePass() = default;
+
+void PostprocessWipePass::prepass(Rhi& rhi)
+{
+	if (!render_pass_)
+	{
+		render_pass_ = rhi.create_render_pass(
+			{std::nullopt, PixelFormat::kRGBA8, AttachmentLoadOp::kLoad, AttachmentStoreOp::kStore}
+		);
+	}
+
+	if (!pipeline_)
+	{
+		pipeline_ = rhi.create_pipeline(kWipePipelineDesc);
+	}
+
+	if (!vbo_)
+	{
+		vbo_ = rhi.create_buffer({sizeof(PostprocessVertex) * 4, BufferType::kVertexBuffer, BufferUsage::kImmutable});
+		upload_vbo_ = true;
+	}
+	if (!ibo_)
+	{
+		ibo_ = rhi.create_buffer({2 * 6, BufferType::kIndexBuffer, BufferUsage::kImmutable});
+		upload_ibo_ = true;
+	}
+
+	uint32_t wipe_type = g_wipetype;
+	uint32_t wipe_frame = g_wipeframe;
+	bool wipe_reverse = g_wipereverse;
+	if (wipe_type >= 100 || wipe_frame >= 100)
+	{
+		return;
+	}
+
+	std::string lumpname = fmt::format(FMT_STRING("FADE{:02d}{:02d}"), wipe_type, wipe_frame);
+	lumpnum_t mask_lump = W_CheckNumForName(lumpname.c_str());
+	if (mask_lump == LUMPERROR)
+	{
+		return;
+	}
+
+	std::size_t mask_lump_size = W_LumpLength(mask_lump);
+	switch (mask_lump_size)
+	{
+	case 256000:
+		mask_w_ = 640;
+		mask_h_ = 400;
+		break;
+	case 64000:
+		mask_w_ = 320;
+		mask_h_ = 200;
+		break;
+	case 16000:
+		mask_w_ = 160;
+		mask_h_ = 100;
+		break;
+	case 4000:
+		mask_w_ = 80;
+		mask_h_ = 50;
+		break;
+	default:
+		return;
+	}
+
+	mask_data_.clear();
+	mask_data_.resize(mask_lump_size, 0);
+	W_ReadLump(mask_lump, mask_data_.data());
+	if (wipe_reverse)
+	{
+		for (auto& b : mask_data_)
+		{
+			b = 32 - b;
+		}
+	}
+
+	wipe_tex_ = rhi.create_texture({TextureFormat::kLuminance, mask_w_, mask_h_});
+}
+
+void PostprocessWipePass::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+{
+	if (wipe_tex_ == kNullHandle)
+	{
+		return;
+	}
+
+	if (source_ == kNullHandle)
+	{
+		return;
+	}
+
+	if (upload_vbo_)
+	{
+		rhi.update_buffer_contents(ctx, vbo_, 0, tcb::as_bytes(tcb::span(kPostprocessVerts)));
+		upload_vbo_ = false;
+	}
+
+	if (upload_ibo_)
+	{
+		rhi.update_buffer_contents(ctx, ibo_, 0, tcb::as_bytes(tcb::span(kPostprocessIndices)));
+		upload_ibo_ = false;
+	}
+
+	tcb::span<const std::byte> data = tcb::as_bytes(tcb::span(mask_data_));
+	rhi.update_texture(ctx, wipe_tex_, {0, 0, mask_w_, mask_h_}, PixelFormat::kR8, data);
+
+	UniformVariant uniforms[] = {
+		{// Projection
+		 std::array<std::array<float, 4>, 4> {
+			 {{2.f, 0.f, 0.f, 0.f}, {0.f, 2.f, 0.f, 0.f}, {0.f, 0.f, 1.f, 0.f}, {0.f, 0.f, 0.f, 1.f}}}}};
+	us_ = rhi.create_uniform_set(ctx, {tcb::span(uniforms)});
+
+	VertexAttributeBufferBinding vbos[] = {{0, vbo_}};
+	TextureBinding tx[] = {{SamplerName::kSampler0, source_}, {SamplerName::kSampler1, wipe_tex_}};
+	bs_ = rhi.create_binding_set(ctx, pipeline_, {vbos, tx});
+}
+
+void PostprocessWipePass::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+{
+	if (wipe_tex_ == kNullHandle)
+	{
+		return;
+	}
+
+	if (target_)
+	{
+		rhi.begin_render_pass(ctx, {render_pass_, target_, std::nullopt, {0, 0, 0, 1}});
+	}
+	else
+	{
+		rhi.begin_default_render_pass(ctx, false);
+	}
+
+	rhi.bind_pipeline(ctx, pipeline_);
+	if (target_)
+	{
+		rhi.set_viewport(ctx, {0, 0, target_w_, target_h_});
+	}
+	rhi.bind_uniform_set(ctx, 0, us_);
+	rhi.bind_binding_set(ctx, bs_);
+	rhi.bind_index_buffer(ctx, ibo_);
+	rhi.draw_indexed(ctx, 6, 0);
+
+	rhi.end_render_pass(ctx);
+}
+
+void PostprocessWipePass::postpass(Rhi& rhi)
+{
+	if (wipe_tex_)
+	{
+		rhi.destroy_texture(wipe_tex_);
+		wipe_tex_ = kNullHandle;
+	}
+
+	mask_data_.clear();
+}
diff --git a/src/hwr2/pass_postprocess.hpp b/src/hwr2/pass_postprocess.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..113d96296c59fa92844bc1cf732ce0b675d4bd0b
--- /dev/null
+++ b/src/hwr2/pass_postprocess.hpp
@@ -0,0 +1,71 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#ifndef __SRB2_HWR2_PASS_POSTPROCESS_HPP__
+#define __SRB2_HWR2_PASS_POSTPROCESS_HPP__
+
+#include "pass.hpp"
+
+#include <vector>
+
+namespace srb2::hwr2
+{
+
+class PostprocessWipePass final : public Pass
+{
+	rhi::Handle<rhi::RenderPass> render_pass_;
+	rhi::Handle<rhi::Pipeline> pipeline_;
+	rhi::Handle<rhi::Buffer> vbo_;
+	bool upload_vbo_ = false;
+	rhi::Handle<rhi::Buffer> ibo_;
+	bool upload_ibo_ = false;
+	rhi::Handle<rhi::UniformSet> us_;
+	rhi::Handle<rhi::BindingSet> bs_;
+	rhi::Handle<rhi::Texture> wipe_tex_;
+	rhi::Handle<rhi::Texture> source_;
+	uint32_t source_w_ = 0;
+	uint32_t source_h_ = 0;
+	rhi::Handle<rhi::Texture> end_;
+	rhi::Handle<rhi::Texture> target_;
+	uint32_t target_w_ = 0;
+	uint32_t target_h_ = 0;
+
+	std::vector<uint8_t> mask_data_;
+	uint32_t mask_w_ = 0;
+	uint32_t mask_h_ = 0;
+
+public:
+	PostprocessWipePass();
+	virtual ~PostprocessWipePass();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+	virtual void postpass(rhi::Rhi& rhi) override;
+
+	void set_source(rhi::Handle<rhi::Texture> source, uint32_t width, uint32_t height) noexcept
+	{
+		source_ = source;
+		source_w_ = width;
+		source_h_ = height;
+	}
+
+	void set_end(rhi::Handle<rhi::Texture> end) noexcept { end_ = end; }
+
+	void set_target(rhi::Handle<rhi::Texture> target, uint32_t width, uint32_t height) noexcept
+	{
+		target_ = target;
+		target_w_ = width;
+		target_h_ = height;
+	}
+};
+
+} // namespace srb2::hwr2
+
+#endif // __SRB2_HWR2_PASS_POSTPROCESS_HPP__
diff --git a/src/hwr2/pass_resource_managers.cpp b/src/hwr2/pass_resource_managers.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..2f5e599c4743bd34f19c5b95b83f31e32d22447a
--- /dev/null
+++ b/src/hwr2/pass_resource_managers.cpp
@@ -0,0 +1,236 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#include "pass_resource_managers.hpp"
+
+#include <algorithm>
+#include <cmath>
+
+#include "../v_video.h"
+#include "../z_zone.h"
+
+using namespace srb2;
+using namespace srb2::hwr2;
+using namespace srb2::rhi;
+
+FramebufferManager::FramebufferManager() : Pass()
+{
+}
+
+FramebufferManager::~FramebufferManager() = default;
+
+void FramebufferManager::prepass(Rhi& rhi)
+{
+	uint32_t current_width = vid.width;
+	uint32_t current_height = vid.height;
+
+	// Destroy the framebuffer textures if they exist and the video size changed
+	if (width_ != current_width || height_ != current_height)
+	{
+		if (main_colors_[0] != kNullHandle)
+		{
+			rhi.destroy_texture(main_colors_[0]);
+			main_colors_[0] = kNullHandle;
+		}
+		if (main_colors_[1] != kNullHandle)
+		{
+			rhi.destroy_texture(main_colors_[1]);
+			main_colors_[1] = kNullHandle;
+		}
+		if (main_depth_ != kNullHandle)
+		{
+			rhi.destroy_renderbuffer(main_depth_);
+			main_depth_ = kNullHandle;
+		}
+
+		if (post_colors_[0] != kNullHandle)
+		{
+			rhi.destroy_texture(post_colors_[0]);
+			post_colors_[0] = kNullHandle;
+		}
+		if (post_colors_[1] != kNullHandle)
+		{
+			rhi.destroy_texture(post_colors_[1]);
+			post_colors_[1] = kNullHandle;
+		}
+	}
+	width_ = current_width;
+	height_ = current_height;
+
+	// Recreate the framebuffer textures
+	if (main_colors_[0] == kNullHandle)
+	{
+		main_colors_[0] = rhi.create_texture({TextureFormat::kRGBA, current_width, current_height});
+	}
+	if (main_colors_[1] == kNullHandle)
+	{
+		main_colors_[1] = rhi.create_texture({TextureFormat::kRGBA, current_width, current_height});
+	}
+	if (main_depth_ == kNullHandle)
+	{
+		main_depth_ = rhi.create_renderbuffer({PixelFormat::kDepth16, current_width, current_height});
+	}
+
+	if (post_colors_[0] == kNullHandle)
+	{
+		post_colors_[0] = rhi.create_texture({TextureFormat::kRGBA, current_width, current_height});
+	}
+	if (post_colors_[1] == kNullHandle)
+	{
+		post_colors_[1] = rhi.create_texture({TextureFormat::kRGBA, current_width, current_height});
+	}
+}
+
+void FramebufferManager::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+{
+}
+
+void FramebufferManager::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+{
+}
+
+void FramebufferManager::postpass(Rhi& rhi)
+{
+}
+
+MainPaletteManager::MainPaletteManager() : Pass()
+{
+}
+
+MainPaletteManager::~MainPaletteManager() = default;
+
+void MainPaletteManager::prepass(Rhi& rhi)
+{
+	if (!palette_)
+	{
+		palette_ = rhi.create_texture({TextureFormat::kRGBA, 256, 1});
+	}
+}
+
+void MainPaletteManager::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+{
+	std::array<byteColor_t, 256> palette_32;
+	for (std::size_t i = 0; i < 256; i++)
+	{
+		palette_32[i] = V_GetColor(i).s;
+	}
+	rhi.update_texture(ctx, palette_, {0, 0, 256, 1}, PixelFormat::kRGBA8, tcb::as_bytes(tcb::span(palette_32)));
+}
+
+void MainPaletteManager::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+{
+}
+
+void MainPaletteManager::postpass(Rhi& rhi)
+{
+}
+
+static uint32_t get_flat_size(lumpnum_t lump)
+{
+	SRB2_ASSERT(lump != LUMPERROR);
+
+	std::size_t lumplength = W_LumpLength(lump);
+	if (lumplength == 0)
+	{
+		return 0;
+	}
+
+	if ((lumplength & (lumplength - 1)) != 0)
+	{
+		// Lump length is not a power of two and therefore not a flat.
+		return 0;
+	}
+	uint32_t lumpsize = std::pow(2, std::log2(lumplength) / 2);
+	return lumpsize;
+}
+
+FlatTextureManager::FlatTextureManager() : Pass()
+{
+}
+
+FlatTextureManager::~FlatTextureManager() = default;
+
+void FlatTextureManager::prepass(Rhi& rhi)
+{
+}
+
+void FlatTextureManager::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+{
+	std::vector<std::array<uint8_t, 2>> flat_data;
+	for (auto flat_lump : to_upload_)
+	{
+		flat_data.clear();
+		Handle<Texture> flat_texture = flats_[flat_lump];
+		SRB2_ASSERT(flat_texture != kNullHandle);
+		std::size_t lump_length = W_LumpLength(flat_lump);
+		uint32_t flat_size = get_flat_size(flat_lump);
+		flat_data.reserve(flat_size * flat_size);
+
+		const uint8_t* flat_memory = static_cast<const uint8_t*>(W_CacheLumpNum(flat_lump, PU_PATCH));
+		SRB2_ASSERT(flat_memory != nullptr);
+
+		tcb::span<const uint8_t> flat_bytes = tcb::span(flat_memory, lump_length);
+		for (const uint8_t index : flat_bytes)
+		{
+			// The alpha/green channel is set to 0 if it's index 247; this is not usually used but fake floors can be
+			// masked sometimes, so we need to treat it as transparent when rendering them.
+			// See https://zdoom.org/wiki/Palette for remarks on fake 247 transparency
+			flat_data.push_back({index, index == 247 ? static_cast<uint8_t>(0) : static_cast<uint8_t>(255)});
+		}
+
+		// A flat size of 1 would end up being 2 bytes, so we need 2 more bytes to be unpack-aligned on texture upload
+		// Any other size would implicitly be aligned.
+		// Sure hope nobody tries to load any flats that are too big for the gpu!
+		if (flat_size == 1)
+		{
+			flat_data.push_back({0, 0});
+		}
+
+		tcb::span<const std::byte> data_bytes = tcb::as_bytes(tcb::span(flat_data));
+		rhi.update_texture(ctx, flat_texture, {0, 0, flat_size, flat_size}, rhi::PixelFormat::kRG8, data_bytes);
+	}
+	to_upload_.clear();
+}
+
+void FlatTextureManager::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+{
+}
+
+void FlatTextureManager::postpass(Rhi& rhi)
+{
+}
+
+Handle<Texture> FlatTextureManager::find_or_create_indexed(Rhi& rhi, lumpnum_t lump)
+{
+	SRB2_ASSERT(lump != LUMPERROR);
+
+	auto flat_itr = flats_.find(lump);
+	if (flat_itr != flats_.end())
+	{
+		return flat_itr->second;
+	}
+
+	uint32_t flat_size = get_flat_size(lump);
+	Handle<Texture> new_tex = rhi.create_texture({TextureFormat::kLuminanceAlpha, flat_size, flat_size});
+	flats_.insert({lump, new_tex});
+	to_upload_.push_back(lump);
+	return new_tex;
+}
+
+Handle<Texture> FlatTextureManager::find_indexed(lumpnum_t lump) const
+{
+	SRB2_ASSERT(lump != LUMPERROR);
+
+	auto flat_itr = flats_.find(lump);
+	if (flat_itr != flats_.end())
+	{
+		return flat_itr->second;
+	}
+	return kNullHandle;
+}
diff --git a/src/hwr2/pass_resource_managers.hpp b/src/hwr2/pass_resource_managers.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..4eee36b8c0b5eac7f4f2dfa93cce6284d02e0cc9
--- /dev/null
+++ b/src/hwr2/pass_resource_managers.hpp
@@ -0,0 +1,129 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#ifndef __SRB2_HWR2_PASS_RESOURCE_MANAGERS_HPP__
+#define __SRB2_HWR2_PASS_RESOURCE_MANAGERS_HPP__
+
+#include <array>
+#include <cstddef>
+#include <unordered_map>
+#include <vector>
+
+#include "pass.hpp"
+
+namespace srb2::hwr2
+{
+
+class FramebufferManager final : public Pass
+{
+	std::array<rhi::Handle<rhi::Texture>, 2> main_colors_;
+	rhi::Handle<rhi::Renderbuffer> main_depth_;
+	std::array<rhi::Handle<rhi::Texture>, 2> post_colors_;
+	std::size_t main_index_ = 0;
+	std::size_t post_index_ = 0;
+	std::size_t width_ = 0;
+	std::size_t height_ = 0;
+	bool first_postprocess_ = true;
+
+public:
+	FramebufferManager();
+	virtual ~FramebufferManager();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+	virtual void postpass(rhi::Rhi& rhi) override;
+
+	/// @brief Swap the current and previous main colors.
+	void swap_main() noexcept { main_index_ = main_index_ == 0 ? 1 : 0; }
+
+	/// @brief Swap the current and previous postprocess FB textures. Use between pass prepass phases to alternate.
+	void swap_post() noexcept
+	{
+		post_index_ = post_index_ == 0 ? 1 : 0;
+		first_postprocess_ = false;
+	}
+
+	void reset_post() noexcept { first_postprocess_ = true; }
+
+	rhi::Handle<rhi::Texture> current_main_color() const noexcept { return main_colors_[main_index_]; }
+	rhi::Handle<rhi::Renderbuffer> main_depth() const noexcept { return main_depth_; }
+	rhi::Handle<rhi::Texture> previous_main_color() const noexcept { return main_colors_[1 - main_index_]; }
+
+	rhi::Handle<rhi::Texture> current_post_color() const noexcept { return post_colors_[post_index_]; }
+
+	rhi::Handle<rhi::Texture> previous_post_color() const noexcept
+	{
+		if (first_postprocess_)
+		{
+			return current_main_color();
+		}
+		return post_colors_[1 - post_index_];
+	};
+
+	std::size_t width() const noexcept { return width_; }
+	std::size_t height() const noexcept { return height_; }
+};
+
+class MainPaletteManager final : public Pass
+{
+	rhi::Handle<rhi::Texture> palette_;
+
+public:
+	MainPaletteManager();
+	virtual ~MainPaletteManager();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+	virtual void postpass(rhi::Rhi& rhi) override;
+
+	rhi::Handle<rhi::Texture> palette() const noexcept { return palette_; }
+};
+
+/*
+A note to the reader:
+
+RHI/HWR2's architecture is intentionally decoupled in a data-oriented design fashion. Hash map lookups might technically
+be slower than storing the RHI handle in a hypothetical Flat class object, but it frees us from worrying about the
+validity of a given Handle when the RHI instance changes -- and it _can_, because this is designed to allow multiple
+RHI backends -- because any given Pass must be disposed when the RHI changes. The implementation of I_FinishUpdate is
+such that if the RHI is not the same as before, all passes must be reconstructed, and so we don't have to worry about
+going around and resetting Handle references everywhere. If you're familiar with old GL, it's like decoupling GLmipmap_t
+from patch_t.
+*/
+
+/// @brief Manages textures corresponding to specific flats indexed by lump number.
+class FlatTextureManager final : public Pass
+{
+	std::unordered_map<lumpnum_t, rhi::Handle<rhi::Texture>> flats_;
+	std::vector<lumpnum_t> to_upload_;
+	std::vector<rhi::Handle<rhi::Texture>> disposed_textures_;
+
+public:
+	FlatTextureManager();
+	virtual ~FlatTextureManager();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+	virtual void postpass(rhi::Rhi& rhi) override;
+
+	/// @brief Find the indexed texture for a given flat lump, or create one if it doesn't exist yet. Only call this
+	/// in prepass.
+	/// @param flat_lump
+	/// @return
+	rhi::Handle<rhi::Texture> find_or_create_indexed(rhi::Rhi& rhi, lumpnum_t flat_lump);
+
+	rhi::Handle<rhi::Texture> find_indexed(lumpnum_t flat_lump) const;
+};
+
+} // namespace srb2::hwr2
+
+#endif // __SRB2_HWR2_PASS_RESOURCE_MANAGERS_HPP__
diff --git a/src/hwr2/pass_software.cpp b/src/hwr2/pass_software.cpp
index a94b40169b512b3d040e0980d3ae6b8af044118b..aca51a615fe613e00f58133da7778748c993c9d6 100644
--- a/src/hwr2/pass_software.cpp
+++ b/src/hwr2/pass_software.cpp
@@ -1,10 +1,17 @@
-#include "pass_software.hpp"
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
 
-#include <optional>
+#include "pass_software.hpp"
 
-#include <tcb/span.hpp>
+#include "../i_video.h"
+#include "../v_video.h"
 
-#include "../cxxutil.hpp"
 #include "../d_netcmd.h"
 #ifdef HAVE_DISCORDRPC
 #include "../discord.h"
@@ -13,82 +20,13 @@
 #include "../m_avrecorder.h"
 #include "../st_stuff.h"
 #include "../s_sound.h"
+#include "../st_stuff.h"
 #include "../v_video.h"
 
 using namespace srb2;
 using namespace srb2::hwr2;
 using namespace srb2::rhi;
 
-SoftwareBlitPass::~SoftwareBlitPass() = default;
-
-namespace
-{
-struct SwBlitVertex
-{
-	float x = 0.f;
-	float y = 0.f;
-	float z = 0.f;
-	float u = 0.f;
-	float v = 0.f;
-};
-} // namespace
-
-static const SwBlitVertex kVerts[] =
-{
-	{-.5f, -.5f, 0.f, 0.f, 0.f},
-	{.5f, -.5f, 0.f, 1.f, 0.f},
-	{-.5f, .5f, 0.f, 0.f, 1.f},
-	{.5f, .5f, 0.f, 1.f, 1.f}
-};
-
-static const uint16_t kIndices[] = {0, 1, 2, 1, 3, 2};
-
-static const PipelineDesc kPipelineDescription =
-{
-	PipelineProgram::kUnshadedPaletted,
-	{
-		{
-			{sizeof(SwBlitVertex)}
-		},
-		{
-			{VertexAttributeName::kPosition, 0, 0},
-			{VertexAttributeName::kTexCoord0, 0, 12}
-		}
-	},
-	{{
-		{{UniformName::kProjection}},
-		{{UniformName::kModelView, UniformName::kTexCoord0Transform}}
-	}},
-	{{
-		// R8 index texture
-		SamplerName::kSampler0,
-		// 256x1 palette texture
-		SamplerName::kSampler1
-	}},
-	std::nullopt,
-	{
-		PixelFormat::kRGBA8,
-		std::nullopt,
-		{true, true, true, true}
-	},
-	PrimitiveType::kTriangles,
-	CullMode::kNone,
-	FaceWinding::kCounterClockwise,
-	{0.f, 0.f, 0.f, 1.f}
-};
-
-static uint32_t next_pow_of_2(uint32_t in)
-{
-	in--;
-	in |= in >> 1;
-	in |= in >> 2;
-	in |= in >> 4;
-	in |= in >> 8;
-	in |= in >> 16;
-	in++;
-	return in;
-}
-
 static void temp_legacy_finishupdate_draws()
 {
 	SCR_CalculateFPS();
@@ -100,8 +38,7 @@ static void temp_legacy_finishupdate_draws()
 		if (cv_ticrate.value)
 			SCR_DisplayTicRate();
 
-		if (cv_showping.value && netgame &&
-				( consoleplayer != serverplayer || ! server_lagless ))
+		if (cv_showping.value && netgame && (consoleplayer != serverplayer || !server_lagless))
 		{
 			if (server_lagless)
 			{
@@ -110,11 +47,8 @@ static void temp_legacy_finishupdate_draws()
 			}
 			else
 			{
-				for (
-						int player = 1;
-						player < MAXPLAYERS;
-						player++
-				){
+				for (int player = 1; player < MAXPLAYERS; player++)
+				{
 					if (D_IsPlayerHumanAndGaming(player))
 					{
 						SCR_DisplayLocalPing();
@@ -142,149 +76,83 @@ static void temp_legacy_finishupdate_draws()
 #endif
 }
 
-void SoftwareBlitPass::prepass(Rhi& rhi)
+SoftwarePass::SoftwarePass() : Pass()
 {
-	if (!pipeline_)
-	{
-		pipeline_ = rhi.create_pipeline(kPipelineDescription);
-	}
+}
 
-	if (!quad_vbo_)
-	{
-		quad_vbo_ = rhi.create_buffer({sizeof(kVerts), BufferType::kVertexBuffer, BufferUsage::kImmutable});
-		quad_vbo_needs_upload_ = true;
-	}
+SoftwarePass::~SoftwarePass() = default;
 
-	if (!quad_ibo_)
+void SoftwarePass::prepass(Rhi& rhi)
+{
+	if (rendermode != render_soft)
 	{
-		quad_ibo_ = rhi.create_buffer({sizeof(kIndices), BufferType::kIndexBuffer, BufferUsage::kImmutable});
-		quad_ibo_needs_upload_ = true;
+		return;
 	}
 
-	temp_legacy_finishupdate_draws();
+	// Render the player views... or not yet? Needs to be moved out of D_Display in d_main.c
+	// Assume it's already been done and vid.buffer contains the composited splitscreen view.
+	// In the future though, we will want to treat each player viewport separately for postprocessing.
 
-	uint32_t vid_width = static_cast<uint32_t>(vid.width);
-	uint32_t vid_height = static_cast<uint32_t>(vid.height);
+	temp_legacy_finishupdate_draws();
 
-	if (screen_tex_ && (screen_tex_width_ < vid_width || screen_tex_height_ < vid_height))
+	// Prepare RHI resources
+	if (screen_texture_ && (static_cast<int32_t>(width_) != vid.width || static_cast<int32_t>(height_) != vid.height))
 	{
-		rhi.destroy_texture(screen_tex_);
-		screen_tex_ = kNullHandle;
+		// Mode changed, recreate texture
+		rhi.destroy_texture(screen_texture_);
+		screen_texture_ = kNullHandle;
 	}
 
-	if (!screen_tex_)
-	{
-		screen_tex_width_ = next_pow_of_2(vid_width);
-		screen_tex_height_ = next_pow_of_2(vid_height);
-		screen_tex_ = rhi.create_texture({TextureFormat::kLuminance, screen_tex_width_, screen_tex_height_});
-	}
+	width_ = vid.width;
+	height_ = vid.height;
 
-	if (!palette_tex_)
+	if (!screen_texture_)
 	{
-		palette_tex_ = rhi.create_texture({TextureFormat::kRGBA, 256, 1});
+		screen_texture_ = rhi.create_texture({TextureFormat::kLuminance, width_, height_});
 	}
-}
 
-void SoftwareBlitPass::upload_screen(Rhi& rhi, Handle<TransferContext> ctx)
-{
-	rhi::Rect screen_rect = {
-		0,
-		0,
-		static_cast<uint32_t>(vid.width),
-		static_cast<uint32_t>(vid.height)
-	};
-
-	tcb::span<uint8_t> screen_span = tcb::span(vid.buffer, static_cast<size_t>(vid.width * vid.height));
-	rhi.update_texture(ctx, screen_tex_, screen_rect, rhi::PixelFormat::kR8, tcb::as_bytes(screen_span));
-}
-
-void SoftwareBlitPass::upload_palette(Rhi& rhi, Handle<TransferContext> ctx)
-{
-	// Unfortunately, pMasterPalette must be swizzled to get a linear layout.
-	// Maybe some adjustments to palette storage can make this a straight upload.
-	std::array<byteColor_t, 256> palette_32;
-	for (size_t i = 0; i < 256; i++)
+	// If the screen width won't fit the unpack alignment, we need to copy the screen.
+	if (width_ % kPixelRowUnpackAlignment > 0)
 	{
-		palette_32[i] = pMasterPalette[i].s;
+		std::size_t padded_width = (width_ + (kPixelRowUnpackAlignment - 1)) & !kPixelRowUnpackAlignment;
+		copy_buffer_.clear();
+		copy_buffer_.reserve(padded_width * height_);
+		for (std::size_t y = 0; y < height_; y++)
+		{
+			for (std::size_t x = 0; x < width_; x++)
+			{
+				copy_buffer_.push_back(vid.buffer[(width_ * y) + x]);
+			}
+
+			// Padding to unpack alignment
+			for (std::size_t i = 0; i < padded_width - width_; i++)
+			{
+				copy_buffer_.push_back(0);
+			}
+		}
 	}
-	rhi.update_texture(ctx, palette_tex_, {0, 0, 256, 1}, rhi::PixelFormat::kRGBA8, tcb::as_bytes(tcb::span(palette_32)));
 }
 
-void SoftwareBlitPass::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+void SoftwarePass::transfer(Rhi& rhi, Handle<TransferContext> ctx)
 {
-	if (quad_vbo_needs_upload_ && quad_vbo_)
+	// Upload screen
+	tcb::span<const std::byte> screen_span;
+	if (width_ % kPixelRowUnpackAlignment > 0)
 	{
-		rhi.update_buffer_contents(ctx, quad_vbo_, 0, tcb::as_bytes(tcb::span(kVerts)));
-		quad_vbo_needs_upload_ = false;
+		screen_span = tcb::as_bytes(tcb::span(copy_buffer_));
 	}
-
-	if (quad_ibo_needs_upload_ && quad_ibo_)
+	else
 	{
-		rhi.update_buffer_contents(ctx, quad_ibo_, 0, tcb::as_bytes(tcb::span(kIndices)));
-		quad_ibo_needs_upload_ = false;
+		screen_span = tcb::as_bytes(tcb::span(vid.buffer, width_ * height_));
 	}
 
-	upload_screen(rhi, ctx);
-	upload_palette(rhi, ctx);
-
-	// Calculate aspect ratio for black borders
-	float aspect = static_cast<float>(vid.width) / static_cast<float>(vid.height);
-	float real_aspect = static_cast<float>(vid.realwidth) / static_cast<float>(vid.realheight);
-	bool taller = aspect > real_aspect;
-
-	std::array<rhi::UniformVariant, 1> g1_uniforms = {{
-		// Projection
-		std::array<std::array<float, 4>, 4> {{
-			{taller ? 1.f : 1.f / real_aspect, 0.f, 0.f, 0.f},
-			{0.f, taller ? -1.f / (1.f / real_aspect) : -1.f, 0.f, 0.f},
-			{0.f, 0.f, 1.f, 0.f},
-			{0.f, 0.f, 0.f, 1.f}
-		}},
-	}};
-
-	std::array<rhi::UniformVariant, 2> g2_uniforms =
-	{{
-		// ModelView
-		std::array<std::array<float, 4>, 4>
-		{{
-			{taller ? 2.f : 2.f * aspect, 0.f, 0.f, 0.f},
-			{0.f, taller ? 2.f * (1.f / aspect) : 2.f, 0.f, 0.f},
-			{0.f, 0.f, 1.f, 0.f},
-			{0.f, 0.f, 0.f, 1.f}
-		}},
-		// Texcoord0 Transform
-		std::array<std::array<float, 3>, 3>
-		{{
-			{vid.width / static_cast<float>(screen_tex_width_), 0.f, 0.f},
-			{0.f, vid.height / static_cast<float>(screen_tex_height_), 0.f},
-			{0.f, 0.f, 1.f}
-		}}
-	}};
-
-	uniform_sets_[0] = rhi.create_uniform_set(ctx, {g1_uniforms});
-	uniform_sets_[1] = rhi.create_uniform_set(ctx, {g2_uniforms});
-
-	std::array<rhi::VertexAttributeBufferBinding, 1> vbs = {{{0, quad_vbo_}}};
-	std::array<rhi::TextureBinding, 2> tbs = {{
-		{rhi::SamplerName::kSampler0, screen_tex_},
-		{rhi::SamplerName::kSampler1, palette_tex_}
-	}};
-	binding_set_ = rhi.create_binding_set(ctx, pipeline_, {vbs, tbs});
+	rhi.update_texture(ctx, screen_texture_, {0, 0, width_, height_}, PixelFormat::kR8, screen_span);
 }
 
-void SoftwareBlitPass::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+void SoftwarePass::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
 {
-	rhi.begin_default_render_pass(ctx, true);
-	rhi.bind_pipeline(ctx, pipeline_);
-	rhi.bind_uniform_set(ctx, 0, uniform_sets_[0]);
-	rhi.bind_uniform_set(ctx, 1, uniform_sets_[1]);
-	rhi.bind_binding_set(ctx, binding_set_);
-	rhi.bind_index_buffer(ctx, quad_ibo_);
-	rhi.draw_indexed(ctx, 6, 0);
-	rhi.end_render_pass(ctx);
 }
 
-void SoftwareBlitPass::postpass(Rhi& rhi)
+void SoftwarePass::postpass(Rhi& rhi)
 {
-	// no-op
 }
diff --git a/src/hwr2/pass_software.hpp b/src/hwr2/pass_software.hpp
index f36c82973a2b5a4dd686c0b9cc4fe622a6f9096c..4e7b02405f561afb54da23f8012b689f91d17bc8 100644
--- a/src/hwr2/pass_software.hpp
+++ b/src/hwr2/pass_software.hpp
@@ -1,44 +1,46 @@
-#ifndef __SRB2_HWR2_PASS_SOFTWARE_HPP__
-#define __SRB2_HWR2_PASS_SOFTWARE_HPP__
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
 
-#include <array>
+#ifndef __SRB2_HWR2_PASS_SOFTWARE_HPP_
+#define __SRB2_HWR2_PASS_SOFTWARE_HPP_
+
+#include <cstddef>
+#include <vector>
 
-#include "../rhi/rhi.hpp"
 #include "pass.hpp"
 
 namespace srb2::hwr2
 {
 
-class SoftwareBlitPass : public Pass
+/// @brief Renders software player views in prepass and uploads the result to a texture in transfer.
+class SoftwarePass final : public Pass
 {
-	rhi::Handle<rhi::Pipeline> pipeline_;
-	rhi::Handle<rhi::Texture> screen_tex_;
-	rhi::Handle<rhi::Texture> palette_tex_;
-	rhi::Handle<rhi::Buffer> quad_vbo_;
-	rhi::Handle<rhi::Buffer> quad_ibo_;
-	std::array<rhi::Handle<rhi::UniformSet>, 2> uniform_sets_;
-	rhi::Handle<rhi::BindingSet> binding_set_;
-
-	uint32_t screen_tex_width_ = 0;
-	uint32_t screen_tex_height_ = 0;
-	bool quad_vbo_needs_upload_ = false;
-	bool quad_ibo_needs_upload_ = false;
-
-	void upload_screen(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx);
-	void upload_palette(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx);
+	rhi::Handle<rhi::Texture> screen_texture_;
+	uint32_t width_ = 0;
+	uint32_t height_ = 0;
+
+	// Used to ensure the row spans are aligned on the unpack boundary for weird resolutions
+	// Any resolution with a width divisible by 4 doesn't need this, but e.g. 1366x768 needs the intermediary copy
+	std::vector<uint8_t> copy_buffer_;
 
 public:
-	virtual ~SoftwareBlitPass();
+	SoftwarePass();
+	virtual ~SoftwarePass();
 
 	virtual void prepass(rhi::Rhi& rhi) override;
-
 	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
-
 	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
-
 	virtual void postpass(rhi::Rhi& rhi) override;
+
+	rhi::Handle<rhi::Texture> screen_texture() const noexcept { return screen_texture_; }
 };
 
 } // namespace srb2::hwr2
 
-#endif // __SRB2_HWR2_PASS_SOFTWARE_HPP__
+#endif // __SRB2_HWR2_PASS_SOFTWARE_HPP_
diff --git a/src/hwr2/pass_twodee.cpp b/src/hwr2/pass_twodee.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..469fdcd2aff00b28d0020788117f07d3ed5ccde7
--- /dev/null
+++ b/src/hwr2/pass_twodee.cpp
@@ -0,0 +1,954 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#include "pass_twodee.hpp"
+
+#include <unordered_set>
+
+#include <stb_rect_pack.h>
+
+#include "../r_patch.h"
+#include "../v_video.h"
+#include "../z_zone.h"
+
+using namespace srb2;
+using namespace srb2::hwr2;
+using namespace srb2::rhi;
+
+namespace
+{
+
+struct AtlasEntry
+{
+	uint32_t x;
+	uint32_t y;
+	uint32_t w;
+	uint32_t h;
+
+	uint32_t trim_x;
+	uint32_t trim_y;
+	uint32_t orig_w;
+	uint32_t orig_h;
+};
+
+struct Atlas
+{
+	Atlas() = default;
+	Atlas(Atlas&&) = default;
+
+	Handle<Texture> tex;
+	uint32_t tex_width;
+	uint32_t tex_height;
+	std::unordered_map<const patch_t*, AtlasEntry> entries;
+
+	std::unique_ptr<stbrp_context> rp_ctx {nullptr};
+	std::unique_ptr<stbrp_node[]> rp_nodes {nullptr};
+
+	Atlas& operator=(Atlas&&) = default;
+};
+
+} // namespace
+
+struct srb2::hwr2::TwodeePassData
+{
+	Handle<Texture> default_tex;
+	Handle<Texture> palette_tex;
+	Handle<Texture> default_colormap_tex;
+	std::vector<Atlas> patch_atlases;
+	std::unordered_map<const patch_t*, size_t> patch_lookup;
+	std::vector<const patch_t*> patches_to_upload;
+	std::unordered_map<const uint8_t*, Handle<Texture>> colormaps;
+	std::vector<const uint8_t*> colormaps_to_upload;
+	std::unordered_map<TwodeePipelineKey, Handle<Pipeline>> pipelines;
+	bool upload_default_tex = false;
+};
+
+std::shared_ptr<TwodeePassData> srb2::hwr2::make_twodee_pass_data()
+{
+	return std::make_shared<TwodeePassData>();
+}
+
+TwodeePass::TwodeePass() : Pass()
+{
+}
+
+TwodeePass::~TwodeePass() = default;
+
+static constexpr const uint32_t kVboInitSize = 32768;
+static constexpr const uint32_t kIboInitSize = 4096;
+
+static Rect trimmed_patch_dim(const patch_t* patch);
+
+static void create_atlas(Rhi& rhi, TwodeePassData& pass_data)
+{
+	Atlas new_atlas;
+	new_atlas.tex = rhi.create_texture({TextureFormat::kLuminanceAlpha, 2048, 2048});
+	new_atlas.tex_width = 2048;
+	new_atlas.tex_height = 2048;
+	new_atlas.rp_ctx = std::make_unique<stbrp_context>();
+	new_atlas.rp_nodes = std::make_unique<stbrp_node[]>(4096);
+	for (size_t i = 0; i < 4096; i++)
+	{
+		new_atlas.rp_nodes[i] = {};
+	}
+	stbrp_init_target(new_atlas.rp_ctx.get(), 2048, 2048, new_atlas.rp_nodes.get(), 4096);
+	// it is CRITICALLY important that the atlas is MOVED, not COPIED, otherwise the node ptrs will be broken
+	pass_data.patch_atlases.push_back(std::move(new_atlas));
+}
+
+static void pack_patches(Rhi& rhi, TwodeePassData& pass_data, tcb::span<const patch_t*> patches)
+{
+	// Prepare stbrp rects for patches to be loaded.
+	std::vector<stbrp_rect> rects;
+	for (size_t i = 0; i < patches.size(); i++)
+	{
+		const patch_t* patch = patches[i];
+		Rect trimmed_rect = trimmed_patch_dim(patch);
+		stbrp_rect rect {};
+		rect.id = i;
+		rect.w = trimmed_rect.w;
+		rect.h = trimmed_rect.h;
+		rects.push_back(std::move(rect));
+	}
+
+	while (rects.size() > 0)
+	{
+		if (pass_data.patch_atlases.size() == 0)
+		{
+			create_atlas(rhi, pass_data);
+		}
+
+		for (size_t atlas_index = 0; atlas_index < pass_data.patch_atlases.size(); atlas_index++)
+		{
+			auto& atlas = pass_data.patch_atlases[atlas_index];
+
+			stbrp_pack_rects(atlas.rp_ctx.get(), rects.data(), rects.size());
+			for (auto itr = rects.begin(); itr != rects.end();)
+			{
+				auto& rect = *itr;
+				if (rect.was_packed)
+				{
+					AtlasEntry entry;
+					const patch_t* patch = patches[rect.id];
+					// TODO prevent unnecessary recalculation of trim?
+					Rect trimmed_rect = trimmed_patch_dim(patch);
+					entry.x = static_cast<uint32_t>(rect.x);
+					entry.y = static_cast<uint32_t>(rect.y);
+					entry.w = static_cast<uint32_t>(rect.w);
+					entry.h = static_cast<uint32_t>(rect.h);
+					entry.trim_x = static_cast<uint32_t>(trimmed_rect.x);
+					entry.trim_y = static_cast<uint32_t>(trimmed_rect.y);
+					entry.orig_w = static_cast<uint32_t>(patch->width);
+					entry.orig_h = static_cast<uint32_t>(patch->height);
+					atlas.entries.insert_or_assign(patch, std::move(entry));
+					pass_data.patch_lookup.insert_or_assign(patch, atlas_index);
+					pass_data.patches_to_upload.push_back(patch);
+					rects.erase(itr);
+					continue;
+				}
+				++itr;
+			}
+
+			// If we still have rects to pack, and we're at the last atlas, create another atlas.
+			// TODO This could end up in an infinite loop if the patches are bigger than an atlas. Such patches need to
+			// be loaded as individual RHI textures instead.
+			if (atlas_index == pass_data.patch_atlases.size() - 1 && rects.size() > 0)
+			{
+				create_atlas(rhi, pass_data);
+			}
+		}
+	}
+}
+
+/// @brief Derive the subrect of the given patch with empty columns and rows excluded.
+static Rect trimmed_patch_dim(const patch_t* patch)
+{
+	bool minx_found = false;
+	int32_t minx = 0;
+	int32_t maxx = 0;
+	int32_t miny = patch->height;
+	int32_t maxy = 0;
+	for (int32_t x = 0; x < patch->width; x++)
+	{
+		const int32_t columnofs = patch->columnofs[x];
+		const column_t* column = reinterpret_cast<const column_t*>(patch->columns + columnofs);
+
+		// If the first pole is empty (topdelta = 255), there are no pixels in this column
+		if (!minx_found && column->topdelta == 0xFF)
+		{
+			// Thus, the minx is at least one higher than the current column.
+			minx = x + 1;
+			continue;
+		}
+		minx_found = true;
+
+		if (minx_found && column->topdelta != 0xFF)
+		{
+			maxx = x;
+		}
+
+		miny = std::min(static_cast<int32_t>(column->topdelta), miny);
+
+		int32_t prevdelta = 0;
+		int32_t topdelta = 0;
+		while (column->topdelta != 0xFF)
+		{
+			topdelta = column->topdelta;
+
+			// Tall patches hack
+			if (topdelta <= prevdelta)
+			{
+				topdelta += prevdelta;
+			}
+			prevdelta = topdelta;
+
+			maxy = std::max(topdelta + column->length, maxy);
+
+			column = reinterpret_cast<const column_t*>(reinterpret_cast<const uint8_t*>(column) + column->length + 4);
+		}
+	}
+
+	maxx += 1;
+	maxx = std::max(minx, maxx);
+	maxy = std::max(miny, maxy);
+
+	return {minx, miny, static_cast<uint32_t>(maxx - minx), static_cast<uint32_t>(maxy - miny)};
+}
+
+static void convert_patch_to_trimmed_rg8_pixels(const patch_t* patch, std::vector<uint8_t>& out)
+{
+	Rect trimmed_rect = trimmed_patch_dim(patch);
+	if (trimmed_rect.w % 2 > 0)
+	{
+		// In order to force 4-byte row alignment, an extra column is added to the image data.
+		// Look up GL_UNPACK_ALIGNMENT (which defaults to 4 bytes)
+		trimmed_rect.w += 1;
+	}
+	out.clear();
+	// 2 bytes per pixel; 1 for the color index, 1 for the alpha. (RG8)
+	out.resize(trimmed_rect.w * trimmed_rect.h * 2, 0);
+	for (int32_t x = 0; x < static_cast<int32_t>(trimmed_rect.w) && x < (patch->width - trimmed_rect.x); x++)
+	{
+		const int32_t columnofs = patch->columnofs[x + trimmed_rect.x];
+		const column_t* column = reinterpret_cast<const column_t*>(patch->columns + columnofs);
+
+		int32_t prevdelta = 0;
+		int32_t topdelta = 0;
+		while (column->topdelta != 0xFF)
+		{
+			topdelta = column->topdelta;
+			// prevdelta is used to implement tall patches hack
+			if (topdelta <= prevdelta)
+			{
+				topdelta += prevdelta;
+			}
+
+			prevdelta = topdelta;
+			const uint8_t* source = reinterpret_cast<const uint8_t*>(column) + 3;
+			int32_t count = column->length; // is this byte order safe...?
+
+			for (int32_t i = 0; i < count; i++)
+			{
+				int32_t output_y = topdelta + i - trimmed_rect.y;
+				if (output_y < 0)
+				{
+					continue;
+				}
+				if (output_y >= static_cast<int32_t>(trimmed_rect.h))
+				{
+					break;
+				}
+				size_t pixel_index = (output_y * trimmed_rect.w + x) * 2;
+				out[pixel_index + 0] = source[i]; // index in luminance/red channel
+				out[pixel_index + 1] = 0xFF;	  // alpha/green value of 1
+			}
+			column = reinterpret_cast<const column_t*>(reinterpret_cast<const uint8_t*>(column) + column->length + 4);
+		}
+	}
+}
+
+static TwodeePipelineKey pipeline_key_for_cmd(const Draw2dCmd& cmd)
+{
+	return {hwr2::get_blend_mode(cmd), hwr2::is_draw_lines(cmd)};
+}
+
+static PipelineDesc make_pipeline_desc(TwodeePipelineKey key)
+{
+	constexpr const VertexInputDesc kTwodeeVertexInput = {
+		{{sizeof(TwodeeVertex)}},
+		{{VertexAttributeName::kPosition, 0, 0},
+		 {VertexAttributeName::kTexCoord0, 0, 12},
+		 {VertexAttributeName::kColor, 0, 20}}};
+	BlendDesc blend_desc;
+	switch (key.blend)
+	{
+	case Draw2dBlend::kModulate:
+		blend_desc.source_factor_color = BlendFactor::kSourceAlpha;
+		blend_desc.dest_factor_color = BlendFactor::kOneMinusSourceAlpha;
+		blend_desc.color_function = BlendFunction::kAdd;
+		blend_desc.source_factor_alpha = BlendFactor::kOne;
+		blend_desc.dest_factor_alpha = BlendFactor::kOneMinusSourceAlpha;
+		blend_desc.alpha_function = BlendFunction::kAdd;
+		break;
+	case Draw2dBlend::kAdditive:
+		blend_desc.source_factor_color = BlendFactor::kSourceAlpha;
+		blend_desc.dest_factor_color = BlendFactor::kOne;
+		blend_desc.color_function = BlendFunction::kAdd;
+		blend_desc.source_factor_alpha = BlendFactor::kOne;
+		blend_desc.dest_factor_alpha = BlendFactor::kOneMinusSourceAlpha;
+		blend_desc.alpha_function = BlendFunction::kAdd;
+		break;
+	case Draw2dBlend::kSubtractive:
+		blend_desc.source_factor_color = BlendFactor::kSourceAlpha;
+		blend_desc.dest_factor_color = BlendFactor::kOne;
+		blend_desc.color_function = BlendFunction::kSubtract;
+		blend_desc.source_factor_alpha = BlendFactor::kOne;
+		blend_desc.dest_factor_alpha = BlendFactor::kOneMinusSourceAlpha;
+		blend_desc.alpha_function = BlendFunction::kAdd;
+		break;
+	case Draw2dBlend::kReverseSubtractive:
+		blend_desc.source_factor_color = BlendFactor::kSourceAlpha;
+		blend_desc.dest_factor_color = BlendFactor::kOne;
+		blend_desc.color_function = BlendFunction::kReverseSubtract;
+		blend_desc.source_factor_alpha = BlendFactor::kOne;
+		blend_desc.dest_factor_alpha = BlendFactor::kOneMinusSourceAlpha;
+		blend_desc.alpha_function = BlendFunction::kAdd;
+		break;
+	case Draw2dBlend::kInvertDest:
+		blend_desc.source_factor_color = BlendFactor::kOne;
+		blend_desc.dest_factor_color = BlendFactor::kOne;
+		blend_desc.color_function = BlendFunction::kSubtract;
+		blend_desc.source_factor_alpha = BlendFactor::kZero;
+		blend_desc.dest_factor_alpha = BlendFactor::kDestAlpha;
+		blend_desc.alpha_function = BlendFunction::kAdd;
+		break;
+	}
+
+	return {
+		PipelineProgram::kUnshadedPaletted,
+		kTwodeeVertexInput,
+		{{{{UniformName::kProjection}},
+		  {{UniformName::kModelView, UniformName::kTexCoord0Transform, UniformName::kSampler0IsIndexedAlpha}}}},
+		{{SamplerName::kSampler0, SamplerName::kSampler1, SamplerName::kSampler2}},
+		std::nullopt,
+		{PixelFormat::kRGBA8, blend_desc, {true, true, true, true}},
+		key.lines ? PrimitiveType::kLines : PrimitiveType::kTriangles,
+		CullMode::kNone,
+		FaceWinding::kCounterClockwise,
+		{0.f, 0.f, 0.f, 1.f}};
+}
+
+static void rewrite_patch_quad_vertices(Draw2dList& list, const Draw2dPatchQuad& cmd, TwodeePassData* data)
+{
+	// Patch quads are clipped according to the patch's atlas entry
+	if (cmd.patch == nullptr)
+	{
+		return;
+	}
+
+	std::size_t atlas_index = data->patch_lookup[cmd.patch];
+	auto& atlas = data->patch_atlases[atlas_index];
+	auto& entry = atlas.entries[cmd.patch];
+
+	// Rewrite the vertex data completely.
+	// The UVs of the trimmed patch in atlas UV space.
+	const float atlas_umin = static_cast<float>(entry.x) / atlas.tex_width;
+	const float atlas_umax = static_cast<float>(entry.x + entry.w) / atlas.tex_width;
+	const float atlas_vmin = static_cast<float>(entry.y) / atlas.tex_height;
+	const float atlas_vmax = static_cast<float>(entry.y + entry.h) / atlas.tex_height;
+
+	// The UVs of the trimmed patch in untrimmed UV space.
+	// The command's UVs are in untrimmed UV space.
+	const float trim_umin = static_cast<float>(entry.trim_x) / entry.orig_w;
+	const float trim_umax = static_cast<float>(entry.trim_x + entry.w) / entry.orig_w;
+	const float trim_vmin = static_cast<float>(entry.trim_y) / entry.orig_h;
+	const float trim_vmax = static_cast<float>(entry.trim_y + entry.h) / entry.orig_h;
+
+	// Calculate positions
+	const float cmd_xrange = cmd.xmax - cmd.xmin;
+	const float cmd_yrange = cmd.ymax - cmd.ymin;
+	const float clipped_xmin = cmd.clip ? std::clamp(cmd.xmin, cmd.clip_xmin, cmd.clip_xmax) : cmd.xmin;
+	const float clipped_xmax = cmd.clip ? std::clamp(cmd.xmax, cmd.clip_xmin, cmd.clip_xmax) : cmd.xmax;
+	const float clipped_ymin = cmd.clip ? std::clamp(cmd.ymin, cmd.clip_ymin, cmd.clip_ymax) : cmd.ymin;
+	const float clipped_ymax = cmd.clip ? std::clamp(cmd.ymax, cmd.clip_ymin, cmd.clip_ymax) : cmd.ymax;
+	const float trimmed_xmin = cmd.xmin + trim_umin * cmd_xrange;
+	const float trimmed_xmax = cmd.xmax - (1.f - trim_umax) * cmd_xrange;
+	const float trimmed_ymin = cmd.ymin + trim_vmin * cmd_yrange;
+	const float trimmed_ymax = cmd.ymax - (1.f - trim_vmax) * cmd_yrange;
+	const float trimmed_xrange = trimmed_xmax - trimmed_xmin;
+	const float trimmed_yrange = trimmed_ymax - trimmed_ymin;
+	float clipped_trimmed_xmin = std::max(clipped_xmin, trimmed_xmin);
+	float clipped_trimmed_xmax = std::min(clipped_xmax, trimmed_xmax);
+	float clipped_trimmed_ymin = std::max(clipped_ymin, trimmed_ymin);
+	float clipped_trimmed_ymax = std::min(clipped_ymax, trimmed_ymax);
+	clipped_trimmed_xmin = std::min(clipped_trimmed_xmin, clipped_trimmed_xmax);
+	clipped_trimmed_ymin = std::min(clipped_trimmed_ymin, clipped_trimmed_ymax);
+
+	// Calculate UVs
+	// Start from trimmed dimensions as 0..1 and clip UVs based on that
+	// UVs in trimmed UV space (if clipped_xmin = trimmed_xmin, it'll be 0)
+	float clipped_umin;
+	float clipped_umax;
+	float clipped_vmin;
+	float clipped_vmax;
+
+	if (cmd.flip)
+	{
+		clipped_umin = std::max(0.f, 1.f - (clipped_trimmed_xmin - trimmed_xmin) / trimmed_xrange);
+		clipped_umax = std::min(1.f, (trimmed_xmax - clipped_trimmed_xmax) / trimmed_xrange);
+	}
+	else
+	{
+		clipped_umin = std::min(1.f, (clipped_trimmed_xmin - trimmed_xmin) / trimmed_xrange);
+		clipped_umax = std::max(0.f, 1.f - (trimmed_xmax - clipped_trimmed_xmax) / trimmed_xrange);
+	}
+
+	if (cmd.vflip)
+	{
+		clipped_vmin = std::max(0.f, 1.f - (clipped_trimmed_ymin - trimmed_ymin) / trimmed_yrange);
+		clipped_vmax = std::min(1.f, (trimmed_ymax - clipped_trimmed_ymax) / trimmed_yrange);
+	}
+	else
+	{
+		clipped_vmin = std::min(1.f, 0.f + (clipped_trimmed_ymin - trimmed_ymin) / trimmed_yrange);
+		clipped_vmax = std::max(0.f, 1.f - (trimmed_ymax - clipped_trimmed_ymax) / trimmed_yrange);
+	}
+
+	// convert from trimmed UV space to atlas space
+	clipped_umin = (atlas_umax - atlas_umin) * clipped_umin + atlas_umin;
+	clipped_umax = (atlas_umax - atlas_umin) * clipped_umax + atlas_umin;
+	clipped_vmin = (atlas_vmax - atlas_vmin) * clipped_vmin + atlas_vmin;
+	clipped_vmax = (atlas_vmax - atlas_vmin) * clipped_vmax + atlas_vmin;
+
+	std::size_t vtx_offs = cmd.begin_index;
+	// Vertex order is always min/min, max/min, max/max, min/max
+	list.vertices[vtx_offs + 0].x = clipped_trimmed_xmin;
+	list.vertices[vtx_offs + 0].y = clipped_trimmed_ymin;
+	list.vertices[vtx_offs + 0].u = clipped_umin;
+	list.vertices[vtx_offs + 0].v = clipped_vmin;
+	list.vertices[vtx_offs + 1].x = clipped_trimmed_xmax;
+	list.vertices[vtx_offs + 1].y = clipped_trimmed_ymin;
+	list.vertices[vtx_offs + 1].u = clipped_umax;
+	list.vertices[vtx_offs + 1].v = clipped_vmin;
+	list.vertices[vtx_offs + 2].x = clipped_trimmed_xmax;
+	list.vertices[vtx_offs + 2].y = clipped_trimmed_ymax;
+	list.vertices[vtx_offs + 2].u = clipped_umax;
+	list.vertices[vtx_offs + 2].v = clipped_vmax;
+	list.vertices[vtx_offs + 3].x = clipped_trimmed_xmin;
+	list.vertices[vtx_offs + 3].y = clipped_trimmed_ymax;
+	list.vertices[vtx_offs + 3].u = clipped_umin;
+	list.vertices[vtx_offs + 3].v = clipped_vmax;
+}
+
+void TwodeePass::prepass(Rhi& rhi)
+{
+	if (!ctx_ || !data_)
+	{
+		return;
+	}
+
+	if (data_->pipelines.size() == 0)
+	{
+		TwodeePipelineKey modulate_tris = {Draw2dBlend::kModulate, false};
+		TwodeePipelineKey additive_tris = {Draw2dBlend::kAdditive, false};
+		TwodeePipelineKey subtractive_tris = {Draw2dBlend::kSubtractive, false};
+		TwodeePipelineKey revsubtractive_tris = {Draw2dBlend::kReverseSubtractive, false};
+		TwodeePipelineKey invertdest_tris = {Draw2dBlend::kInvertDest, false};
+		TwodeePipelineKey modulate_lines = {Draw2dBlend::kModulate, true};
+		TwodeePipelineKey additive_lines = {Draw2dBlend::kAdditive, true};
+		TwodeePipelineKey subtractive_lines = {Draw2dBlend::kSubtractive, true};
+		TwodeePipelineKey revsubtractive_lines = {Draw2dBlend::kReverseSubtractive, true};
+		TwodeePipelineKey invertdest_lines = {Draw2dBlend::kInvertDest, true};
+		data_->pipelines.insert({modulate_tris, rhi.create_pipeline(make_pipeline_desc(modulate_tris))});
+		data_->pipelines.insert({additive_tris, rhi.create_pipeline(make_pipeline_desc(additive_tris))});
+		data_->pipelines.insert({subtractive_tris, rhi.create_pipeline(make_pipeline_desc(subtractive_tris))});
+		data_->pipelines.insert({revsubtractive_tris, rhi.create_pipeline(make_pipeline_desc(revsubtractive_tris))});
+		data_->pipelines.insert({invertdest_tris, rhi.create_pipeline(make_pipeline_desc(invertdest_tris))});
+		data_->pipelines.insert({modulate_lines, rhi.create_pipeline(make_pipeline_desc(modulate_lines))});
+		data_->pipelines.insert({additive_lines, rhi.create_pipeline(make_pipeline_desc(additive_lines))});
+		data_->pipelines.insert({subtractive_lines, rhi.create_pipeline(make_pipeline_desc(subtractive_lines))});
+		data_->pipelines.insert({revsubtractive_lines, rhi.create_pipeline(make_pipeline_desc(revsubtractive_lines))});
+		data_->pipelines.insert({invertdest_lines, rhi.create_pipeline(make_pipeline_desc(revsubtractive_lines))});
+	}
+
+	if (!data_->default_tex)
+	{
+		data_->default_tex = rhi.create_texture({TextureFormat::kLuminanceAlpha, 2, 1});
+		data_->upload_default_tex = true;
+	}
+	if (!data_->palette_tex)
+	{
+		data_->palette_tex = rhi.create_texture({TextureFormat::kRGBA, 256, 1});
+	}
+	if (!data_->default_colormap_tex)
+	{
+		data_->default_colormap_tex = rhi.create_texture({TextureFormat::kLuminance, 256, 1});
+		data_->upload_default_tex = true;
+	}
+	if (!render_pass_)
+	{
+		render_pass_ = rhi.create_render_pass(
+			{std::nullopt, PixelFormat::kRGBA8, AttachmentLoadOp::kLoad, AttachmentStoreOp::kStore}
+		);
+	}
+
+	// Check for patches that are being freed after this frame. Those patches must be present in the atlases for this
+	// frame, but all atlases need to be cleared and rebuilt on next call to prepass.
+	// This is based on the assumption that patches are very rarely freed during runtime; occasionally repacking the
+	// atlases to free up space from patches that will never be referenced again is acceptable.
+	if (rebuild_atlases_)
+	{
+		for (auto& atlas : data_->patch_atlases)
+		{
+			rhi.destroy_texture(atlas.tex);
+		}
+		data_->patch_atlases.clear();
+		data_->patch_lookup.clear();
+		rebuild_atlases_ = false;
+	}
+
+	if (data_->patch_atlases.size() > 2)
+	{
+		// Rebuild the atlases next frame because we have too many patches in the atlas cache.
+		rebuild_atlases_ = true;
+	}
+
+	// Stage 1 - command list patch detection
+	std::unordered_set<const patch_t*> found_patches;
+	std::unordered_set<const uint8_t*> found_colormaps;
+	for (const auto& list : *ctx_)
+	{
+		for (const auto& cmd : list.cmds)
+		{
+			auto visitor = srb2::Overload {
+				[&](const Draw2dPatchQuad& cmd)
+				{
+					if (cmd.patch != nullptr)
+					{
+						found_patches.insert(cmd.patch);
+					}
+					if (cmd.colormap != nullptr)
+					{
+						found_colormaps.insert(cmd.colormap);
+					}
+				},
+				[&](const Draw2dVertices& cmd) {}};
+			std::visit(visitor, cmd);
+		}
+	}
+
+	std::unordered_set<const patch_t*> patch_cache_hits;
+	std::unordered_set<const patch_t*> patch_cache_misses;
+	for (auto patch : found_patches)
+	{
+		if (data_->patch_lookup.find(patch) != data_->patch_lookup.end())
+		{
+			patch_cache_hits.insert(patch);
+		}
+		else
+		{
+			patch_cache_misses.insert(patch);
+		}
+	}
+
+	for (auto colormap : found_colormaps)
+	{
+		if (data_->colormaps.find(colormap) == data_->colormaps.end())
+		{
+			Handle<Texture> colormap_tex = rhi.create_texture({TextureFormat::kLuminance, 256, 1});
+			data_->colormaps.insert({colormap, colormap_tex});
+		}
+
+		data_->colormaps_to_upload.push_back(colormap);
+	}
+
+	// Stage 2 - pack rects into atlases
+	std::vector<const patch_t*> patches_to_pack(patch_cache_misses.begin(), patch_cache_misses.end());
+	pack_patches(rhi, *data_, patches_to_pack);
+	// We now know what patches need to be uploaded.
+
+	size_t list_index = 0;
+	for (auto& list : *ctx_)
+	{
+		Handle<Buffer> vbo;
+		uint32_t vertex_data_size = tcb::as_bytes(tcb::span(list.vertices)).size();
+		uint32_t needed_vbo_size = std::max(
+			kVboInitSize,
+			((static_cast<uint32_t>(vertex_data_size) + kVboInitSize - 1) / kVboInitSize) * kVboInitSize
+		);
+
+		// Get the existing buffer objects. Recreate them if they don't exist, or needs to be bigger.
+
+		if (list_index >= vbos_.size())
+		{
+			vbo = rhi.create_buffer({needed_vbo_size, BufferType::kVertexBuffer, BufferUsage::kDynamic});
+			vbos_.push_back({vbo, needed_vbo_size});
+		}
+		else
+		{
+			uint32_t existing_size = std::get<1>(vbos_[list_index]);
+			if (needed_vbo_size > existing_size)
+			{
+				rhi.destroy_buffer(std::get<0>(vbos_[list_index]));
+				vbo = rhi.create_buffer({needed_vbo_size, BufferType::kVertexBuffer, BufferUsage::kDynamic});
+				vbos_[list_index] = {vbo, needed_vbo_size};
+			}
+			vbo = std::get<0>(vbos_[list_index]);
+		}
+
+		Handle<Buffer> ibo;
+		uint32_t index_data_size = tcb::as_bytes(tcb::span(list.indices)).size();
+		uint32_t needed_ibo_size = std::max(
+			kIboInitSize,
+			((static_cast<uint32_t>(index_data_size) + kIboInitSize - 1) / kIboInitSize) * kIboInitSize
+		);
+
+		if (list_index >= ibos_.size())
+		{
+			ibo = rhi.create_buffer({needed_ibo_size, BufferType::kIndexBuffer, BufferUsage::kDynamic});
+			ibos_.push_back({ibo, needed_ibo_size});
+		}
+		else
+		{
+			uint32_t existing_size = std::get<1>(ibos_[list_index]);
+			if (needed_ibo_size > existing_size)
+			{
+				rhi.destroy_buffer(std::get<0>(ibos_[list_index]));
+				ibo = rhi.create_buffer({needed_ibo_size, BufferType::kIndexBuffer, BufferUsage::kDynamic});
+				ibos_[list_index] = {ibo, needed_ibo_size};
+			}
+			ibo = std::get<0>(ibos_[list_index]);
+		}
+
+		// Create a merged command list
+		MergedTwodeeCommandList merged_list;
+		merged_list.vbo = vbo;
+		merged_list.vbo_size = needed_vbo_size;
+		merged_list.ibo = ibo;
+		merged_list.ibo_size = needed_ibo_size;
+
+		MergedTwodeeCommand new_cmd;
+		new_cmd.index_offset = 0;
+		new_cmd.elements = 0;
+		new_cmd.colormap = nullptr;
+		// safety: a command list is required to have at least 1 command
+		new_cmd.pipeline_key = pipeline_key_for_cmd(list.cmds[0]);
+		merged_list.cmds.push_back(std::move(new_cmd));
+
+		for (auto& cmd : list.cmds)
+		{
+			auto& merged_cmd = *merged_list.cmds.rbegin();
+			bool new_cmd_needed = false;
+			TwodeePipelineKey pk = pipeline_key_for_cmd(cmd);
+			new_cmd_needed = new_cmd_needed || (pk != merged_cmd.pipeline_key);
+
+			// We need to split the merged commands based on the kind of texture
+			// Patches are converted to atlas texture indexes, which we've just packed the patch rects for
+			// Flats are uploaded as individual textures.
+			// TODO actually implement flat drawing
+			auto tex_visitor = srb2::Overload {
+				[&](const Draw2dPatchQuad& cmd)
+				{
+					if (cmd.patch == nullptr)
+					{
+						new_cmd_needed = new_cmd_needed || (merged_cmd.texture != std::nullopt);
+					}
+					else
+					{
+						size_t atlas_index = data_->patch_lookup[cmd.patch];
+						typeof(merged_cmd.texture) atlas_index_texture = atlas_index;
+						new_cmd_needed = new_cmd_needed || (merged_cmd.texture != atlas_index_texture);
+					}
+
+					new_cmd_needed = new_cmd_needed || (merged_cmd.colormap != cmd.colormap);
+				},
+				[&](const Draw2dVertices& cmd)
+				{
+					if (cmd.flat_lump == LUMPERROR)
+					{
+						new_cmd_needed |= (merged_cmd.texture != std::nullopt);
+					}
+					else
+					{
+						typeof(merged_cmd.texture) flat_tex = MergedTwodeeCommandFlatTexture {cmd.flat_lump};
+						new_cmd_needed |= (merged_cmd.texture != flat_tex);
+					}
+
+					new_cmd_needed = new_cmd_needed || (merged_cmd.colormap != nullptr);
+				}};
+			std::visit(tex_visitor, cmd);
+
+			if (new_cmd_needed)
+			{
+				MergedTwodeeCommand the_new_one;
+				the_new_one.index_offset = merged_cmd.index_offset + merged_cmd.elements;
+
+				// Map to the merged version of the texture variant. Yay...!
+				auto tex_visitor_again = srb2::Overload {
+					[&](const Draw2dPatchQuad& cmd)
+					{
+						if (cmd.patch != nullptr)
+						{
+							the_new_one.texture = data_->patch_lookup[cmd.patch];
+						}
+						else
+						{
+							the_new_one.texture = std::nullopt;
+						}
+						the_new_one.colormap = cmd.colormap;
+					},
+					[&](const Draw2dVertices& cmd)
+					{
+						if (cmd.flat_lump != LUMPERROR)
+						{
+							flat_manager_->find_or_create_indexed(rhi, cmd.flat_lump);
+							typeof(the_new_one.texture) t = MergedTwodeeCommandFlatTexture {cmd.flat_lump};
+							the_new_one.texture = t;
+						}
+						else
+						{
+							the_new_one.texture = std::nullopt;
+						}
+
+						the_new_one.colormap = nullptr;
+					}};
+				std::visit(tex_visitor_again, cmd);
+				the_new_one.pipeline_key = pipeline_key_for_cmd(cmd);
+				merged_list.cmds.push_back(std::move(the_new_one));
+			}
+
+			// There may or may not be a new current command; update its element count
+			auto& new_merged_cmd = *merged_list.cmds.rbegin();
+			// We know for sure that all commands in a command list have a contiguous range of elements in the IBO
+			// So we can draw them in batch if the pipeline key and textures match
+			new_merged_cmd.elements += hwr2::elements(cmd);
+
+			// Perform coordinate transformations
+			{
+				auto vtx_transform_visitor = srb2::Overload {
+					[&](const Draw2dPatchQuad& cmd) { rewrite_patch_quad_vertices(list, cmd, data_.get()); },
+					[&](const Draw2dVertices& cmd) {}};
+				std::visit(vtx_transform_visitor, cmd);
+			}
+		}
+
+		cmd_lists_.push_back(std::move(merged_list));
+
+		list_index++;
+	}
+}
+
+void TwodeePass::transfer(Rhi& rhi, Handle<TransferContext> ctx)
+{
+	if (!ctx_ || !data_)
+	{
+		return;
+	}
+
+	if (data_->upload_default_tex)
+	{
+		std::array<uint8_t, 4> data = {0, 255, 0, 255};
+		rhi.update_texture(ctx, data_->default_tex, {0, 0, 2, 1}, PixelFormat::kRG8, tcb::as_bytes(tcb::span(data)));
+
+		std::array<uint8_t, 256> colormap_data;
+		for (size_t i = 0; i < 256; i++)
+		{
+			colormap_data[i] = i;
+		}
+		rhi.update_texture(
+			ctx,
+			data_->default_colormap_tex,
+			{0, 0, 256, 1},
+			PixelFormat::kR8,
+			tcb::as_bytes(tcb::span(colormap_data))
+		);
+
+		data_->upload_default_tex = false;
+	}
+
+	{
+		// TODO share palette tex with software pass
+		// Unfortunately, pMasterPalette must be swizzled to get a linear layout.
+		// Maybe some adjustments to palette storage can make this a straight upload.
+		std::array<byteColor_t, 256> palette_32;
+		for (size_t i = 0; i < 256; i++)
+		{
+			palette_32[i] = pMasterPalette[i].s;
+		}
+		rhi.update_texture(
+			ctx,
+			data_->palette_tex,
+			{0, 0, 256, 1},
+			rhi::PixelFormat::kRGBA8,
+			tcb::as_bytes(tcb::span(palette_32))
+		);
+	}
+
+	for (auto colormap : data_->colormaps_to_upload)
+	{
+		rhi.update_texture(
+			ctx,
+			data_->colormaps[colormap],
+			{0, 0, 256, 1},
+			rhi::PixelFormat::kR8,
+			tcb::as_bytes(tcb::span(colormap, 256))
+		);
+	}
+	data_->colormaps_to_upload.clear();
+
+	// Convert patches to RG8 textures and upload to atlas pages
+	std::vector<uint8_t> patch_data;
+	for (const patch_t* patch_to_upload : data_->patches_to_upload)
+	{
+		Atlas& atlas = data_->patch_atlases[data_->patch_lookup[patch_to_upload]];
+		AtlasEntry& entry = atlas.entries[patch_to_upload];
+
+		convert_patch_to_trimmed_rg8_pixels(patch_to_upload, patch_data);
+
+		rhi.update_texture(
+			ctx,
+			atlas.tex,
+			{static_cast<int32_t>(entry.x), static_cast<int32_t>(entry.y), entry.w, entry.h},
+			PixelFormat::kRG8,
+			tcb::as_bytes(tcb::span(patch_data))
+		);
+	}
+	data_->patches_to_upload.clear();
+
+	// Update the buffers for each list
+	auto ctx_list_itr = ctx_->begin();
+	for (size_t i = 0; i < cmd_lists_.size() && ctx_list_itr != ctx_->end(); i++)
+	{
+		auto& merged_list = cmd_lists_[i];
+		auto& orig_list = *ctx_list_itr;
+
+		tcb::span<const std::byte> vertex_data = tcb::as_bytes(tcb::span(orig_list.vertices));
+		tcb::span<const std::byte> index_data = tcb::as_bytes(tcb::span(orig_list.indices));
+		rhi.update_buffer_contents(ctx, merged_list.vbo, 0, vertex_data);
+		rhi.update_buffer_contents(ctx, merged_list.ibo, 0, index_data);
+
+		// Update the binding sets for each individual merged command
+		VertexAttributeBufferBinding vbos[] = {{0, merged_list.vbo}};
+		for (auto& mcmd : merged_list.cmds)
+		{
+			TextureBinding tx[3];
+			auto tex_visitor = srb2::Overload {
+				[&](size_t atlas_index)
+				{
+					Atlas& atlas = data_->patch_atlases[atlas_index];
+					tx[0] = {SamplerName::kSampler0, atlas.tex};
+					tx[1] = {SamplerName::kSampler1, data_->palette_tex};
+				},
+				[&](const MergedTwodeeCommandFlatTexture& tex)
+				{
+					Handle<Texture> th = flat_manager_->find_indexed(tex.lump);
+					SRB2_ASSERT(th != kNullHandle);
+					tx[0] = {SamplerName::kSampler0, th};
+					tx[1] = {SamplerName::kSampler1, data_->palette_tex};
+				}};
+			if (mcmd.texture)
+			{
+				std::visit(tex_visitor, *mcmd.texture);
+			}
+			else
+			{
+				tx[0] = {SamplerName::kSampler0, data_->default_tex};
+				tx[1] = {SamplerName::kSampler1, data_->palette_tex};
+			}
+
+			const uint8_t* colormap = mcmd.colormap;
+			Handle<Texture> colormap_h = data_->default_colormap_tex;
+			if (colormap)
+			{
+				SRB2_ASSERT(data_->colormaps.find(colormap) != data_->colormaps.end());
+				colormap_h = data_->colormaps[colormap];
+			}
+			tx[2] = {SamplerName::kSampler2, colormap_h};
+			mcmd.binding_set =
+				rhi.create_binding_set(ctx, data_->pipelines[mcmd.pipeline_key], {tcb::span(vbos), tcb::span(tx)});
+		}
+
+		ctx_list_itr++;
+	}
+
+	// Uniform sets
+	std::array<UniformVariant, 1> g1_uniforms = {{
+		// Projection
+		std::array<std::array<float, 4>, 4> {
+			{{2.f / vid.width, 0.f, 0.f, 0.f},
+			 {0.f, -2.f / vid.height, 0.f, 0.f},
+			 {0.f, 0.f, 1.f, 0.f},
+			 {-1.f, 1.f, 0.f, 1.f}}},
+	}};
+	std::array<UniformVariant, 3> g2_uniforms = {
+		{// ModelView
+		 std::array<std::array<float, 4>, 4> {
+			 {{1.f, 0.f, 0.f, 0.f}, {0.f, 1.f, 0.f, 0.f}, {0.f, 0.f, 1.f, 0.f}, {0.f, 0.f, 0.f, 1.f}}},
+		 // Texcoord0 Transform
+		 std::array<std::array<float, 3>, 3> {{{1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}}},
+		 // Sampler 0 Is Indexed Alpha (yes, it always is)
+		 static_cast<int32_t>(1)}};
+	us_1 = rhi.create_uniform_set(ctx, {tcb::span(g1_uniforms)});
+	us_2 = rhi.create_uniform_set(ctx, {tcb::span(g2_uniforms)});
+}
+
+static constexpr const rhi::Color kClearColor = {0, 0, 0, 1};
+
+void TwodeePass::graphics(Rhi& rhi, Handle<GraphicsContext> ctx)
+{
+	if (!ctx_ || !data_)
+	{
+		return;
+	}
+
+	if (output_)
+	{
+		rhi.begin_render_pass(ctx, {render_pass_, output_, std::nullopt, kClearColor});
+	}
+	else
+	{
+		rhi.begin_default_render_pass(ctx, false);
+	}
+
+	for (auto& list : cmd_lists_)
+	{
+		for (auto& cmd : list.cmds)
+		{
+			if (cmd.elements == 0)
+			{
+				// Don't do anything for 0-element commands
+				// This shouldn't happen, but, just in case...
+				continue;
+			}
+			SRB2_ASSERT(data_->pipelines.find(cmd.pipeline_key) != data_->pipelines.end());
+			Handle<Pipeline> pl = data_->pipelines[cmd.pipeline_key];
+			rhi.bind_pipeline(ctx, pl);
+			if (output_)
+			{
+				rhi.set_viewport(ctx, {0, 0, output_width_, output_height_});
+			}
+			rhi.bind_uniform_set(ctx, 0, us_1);
+			rhi.bind_uniform_set(ctx, 1, us_2);
+			rhi.bind_binding_set(ctx, cmd.binding_set);
+			rhi.bind_index_buffer(ctx, list.ibo);
+			rhi.draw_indexed(ctx, cmd.elements, cmd.index_offset);
+		}
+	}
+	rhi.end_render_pass(ctx);
+}
+
+void TwodeePass::postpass(Rhi& rhi)
+{
+	if (!ctx_ || !data_)
+	{
+		return;
+	}
+
+	cmd_lists_.clear();
+}
diff --git a/src/hwr2/pass_twodee.hpp b/src/hwr2/pass_twodee.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..da9c2b563215c3bcaa334a10f78e01476df3f8ad
--- /dev/null
+++ b/src/hwr2/pass_twodee.hpp
@@ -0,0 +1,116 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#ifndef __SRB2_HWR2_PASS_TWODEE_HPP__
+#define __SRB2_HWR2_PASS_TWODEE_HPP__
+
+#include <memory>
+#include <optional>
+#include <tuple>
+#include <unordered_map>
+#include <variant>
+#include <vector>
+
+#include "../cxxutil.hpp"
+#include "pass.hpp"
+#include "pass_resource_managers.hpp"
+#include "twodee.hpp"
+
+namespace srb2::hwr2
+{
+
+class TwodeePass;
+
+/// @brief Shared structures to allow multiple 2D instances to share the same atlases
+struct TwodeePassData;
+
+/// @brief Hash map key for caching pipelines
+struct TwodeePipelineKey
+{
+	Draw2dBlend blend;
+	bool lines;
+
+	bool operator==(const TwodeePipelineKey& r) const noexcept { return !(blend != r.blend || lines != r.lines); }
+	bool operator!=(const TwodeePipelineKey& r) const noexcept { return !(*this == r); }
+};
+
+struct MergedTwodeeCommandFlatTexture
+{
+	lumpnum_t lump;
+
+	bool operator==(const MergedTwodeeCommandFlatTexture& rhs) const noexcept { return lump == rhs.lump; }
+	bool operator!=(const MergedTwodeeCommandFlatTexture& rhs) const noexcept { return !(*this == rhs); }
+};
+
+struct MergedTwodeeCommand
+{
+	TwodeePipelineKey pipeline_key = {};
+	rhi::Handle<rhi::BindingSet> binding_set = {};
+	std::optional<std::variant<size_t, MergedTwodeeCommandFlatTexture>> texture;
+	const uint8_t* colormap;
+	uint32_t index_offset = 0;
+	uint32_t elements = 0;
+};
+
+struct MergedTwodeeCommandList
+{
+	rhi::Handle<rhi::Buffer> vbo {};
+	uint32_t vbo_size = 0;
+	rhi::Handle<rhi::Buffer> ibo {};
+	uint32_t ibo_size = 0;
+
+	std::vector<MergedTwodeeCommand> cmds;
+};
+
+std::shared_ptr<TwodeePassData> make_twodee_pass_data();
+
+struct TwodeePass final : public Pass
+{
+	Twodee* ctx_ = nullptr;
+	std::variant<rhi::Handle<rhi::Texture>, rhi::Handle<rhi::Renderbuffer>> out_color_;
+
+	std::shared_ptr<TwodeePassData> data_;
+	std::shared_ptr<FlatTextureManager> flat_manager_;
+	rhi::Handle<rhi::UniformSet> us_1;
+	rhi::Handle<rhi::UniformSet> us_2;
+	std::vector<MergedTwodeeCommandList> cmd_lists_;
+	std::vector<std::tuple<rhi::Handle<rhi::Buffer>, std::size_t>> vbos_;
+	std::vector<std::tuple<rhi::Handle<rhi::Buffer>, std::size_t>> ibos_;
+	bool rebuild_atlases_ = false;
+	rhi::Handle<rhi::RenderPass> render_pass_;
+	rhi::Handle<rhi::Texture> output_;
+	uint32_t output_width_ = 0;
+	uint32_t output_height_ = 0;
+
+	TwodeePass();
+	virtual ~TwodeePass();
+
+	virtual void prepass(rhi::Rhi& rhi) override;
+
+	virtual void transfer(rhi::Rhi& rhi, rhi::Handle<rhi::TransferContext> ctx) override;
+
+	virtual void graphics(rhi::Rhi& rhi, rhi::Handle<rhi::GraphicsContext> ctx) override;
+
+	virtual void postpass(rhi::Rhi& rhi) override;
+};
+
+} // namespace srb2::hwr2
+
+template <>
+struct std::hash<srb2::hwr2::TwodeePipelineKey>
+{
+	std::size_t operator()(const srb2::hwr2::TwodeePipelineKey& v) const
+	{
+		std::size_t hash = 0;
+		srb2::hash_combine(hash, v.blend, v.lines);
+		return hash;
+	}
+};
+
+#endif // __SRB2_HWR2_PASS_TWODEE_HPP__
diff --git a/src/hwr2/twodee.cpp b/src/hwr2/twodee.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8a15234feef3b198098b22001f29815dc8607bbc
--- /dev/null
+++ b/src/hwr2/twodee.cpp
@@ -0,0 +1,114 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#include "twodee.hpp"
+
+#include "../w_wad.h"
+
+using namespace srb2;
+using namespace hwr2;
+
+Twodee::Twodee() = default;
+Twodee::Twodee(const Twodee&) = default;
+Twodee::Twodee(Twodee&&) noexcept = default;
+Twodee& Twodee::operator=(const Twodee&) = default;
+
+// Will the default move prevent the vectors from losing their allocations? I guess it depends on the STL impl.
+// It's probably worth optimizing around.
+Twodee& Twodee::operator=(Twodee&&) noexcept = default;
+
+void Draw2dQuadBuilder::done()
+{
+	if (ctx_.lists_.size() == 0)
+	{
+		ctx_.lists_.push_back({});
+	}
+
+	if (ctx_.lists_.rbegin()->vertices.size() >= (Draw2dList::kMaxVertices - 4))
+	{
+		// The current draw list has too many vertices to fit this command
+		ctx_.lists_.push_back({});
+	}
+
+	auto& list = *ctx_.lists_.rbegin();
+	quad_.begin_element = list.vertices.size();
+	quad_.begin_index = list.vertices.size();
+
+	list.vertices.push_back({quad_.xmin, quad_.ymin, 0.f, 0, 0, quad_.r, quad_.g, quad_.b, quad_.a});
+	list.vertices.push_back({quad_.xmax, quad_.ymin, 0.f, 1, 0, quad_.r, quad_.g, quad_.b, quad_.a});
+	list.vertices.push_back({quad_.xmax, quad_.ymax, 0.f, 1, 1, quad_.r, quad_.g, quad_.b, quad_.a});
+	list.vertices.push_back({quad_.xmin, quad_.ymax, 0.f, 0, 1, quad_.r, quad_.g, quad_.b, quad_.a});
+
+	list.indices.push_back(quad_.begin_element + 0);
+	list.indices.push_back(quad_.begin_element + 1);
+	list.indices.push_back(quad_.begin_element + 2);
+
+	list.indices.push_back(quad_.begin_element + 0);
+	list.indices.push_back(quad_.begin_element + 2);
+	list.indices.push_back(quad_.begin_element + 3);
+
+	list.cmds.push_back(quad_);
+}
+
+void Draw2dVerticesBuilder::done()
+{
+	if (ctx_.lists_.size() == 0)
+	{
+		ctx_.lists_.push_back({});
+	}
+
+	if (ctx_.lists_.rbegin()->vertices.size() >= (Draw2dList::kMaxVertices - 4))
+	{
+		// The current draw list has too many vertices to fit this command
+		ctx_.lists_.push_back({});
+	}
+
+	auto& list = *ctx_.lists_.rbegin();
+	tris_.begin_element = list.vertices.size();
+	tris_.begin_index = list.indices.size();
+
+	if (verts_.empty())
+	{
+		return;
+	}
+
+	std::size_t i = 0;
+	for (auto& vert : verts_)
+	{
+		list.vertices.push_back({vert[0], vert[1], 0, vert[2], vert[3], r_, g_, b_, a_});
+		list.indices.push_back(tris_.begin_element + i);
+		i++;
+	}
+
+	list.cmds.push_back(tris_);
+}
+
+Draw2dBlend srb2::hwr2::get_blend_mode(const Draw2dCmd& cmd) noexcept
+{
+	auto visitor = srb2::Overload {
+		[&](const Draw2dPatchQuad& cmd) { return cmd.blend; },
+		[&](const Draw2dVertices& cmd) { return cmd.blend; }};
+	return std::visit(visitor, cmd);
+}
+
+bool srb2::hwr2::is_draw_lines(const Draw2dCmd& cmd) noexcept
+{
+	auto visitor = srb2::Overload {
+		[&](const Draw2dPatchQuad& cmd) { return false; },
+		[&](const Draw2dVertices& cmd) { return cmd.lines; }};
+	return std::visit(visitor, cmd);
+}
+
+std::size_t srb2::hwr2::elements(const Draw2dCmd& cmd) noexcept
+{
+	auto visitor = srb2::Overload {
+		[&](const Draw2dPatchQuad& cmd) -> std::size_t { return 6; },
+		[&](const Draw2dVertices& cmd) -> std::size_t { return cmd.elements; }};
+	return std::visit(visitor, cmd);
+}
diff --git a/src/hwr2/twodee.hpp b/src/hwr2/twodee.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..10cc8e6b1394e4faef93c7a9b6ed0a9c1fb86890
--- /dev/null
+++ b/src/hwr2/twodee.hpp
@@ -0,0 +1,280 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
+#ifndef __SRB2_HWR2_TWODEE_HPP__
+#define __SRB2_HWR2_TWODEE_HPP__
+
+#include <array>
+#include <cstdint>
+#include <optional>
+#include <utility>
+#include <variant>
+#include <vector>
+
+#include <tcb/span.hpp>
+
+#include "../cxxutil.hpp"
+#include "../doomtype.h"
+
+namespace srb2::hwr2
+{
+
+struct TwodeeVertex
+{
+	float x;
+	float y;
+	float z;
+	float u;
+	float v;
+	float r;
+	float g;
+	float b;
+	float a;
+};
+
+enum class Draw2dBlend
+{
+	kModulate,
+	kAdditive,
+	kSubtractive,
+	kReverseSubtractive,
+	kInvertDest
+};
+
+struct Draw2dPatchQuad
+{
+	std::size_t begin_index = 0;
+	std::size_t begin_element = 0;
+
+	// A null patch ptr means no patch is drawn
+	const patch_t* patch = nullptr;
+	const uint8_t* colormap = nullptr;
+	Draw2dBlend blend;
+	float r = 0.f;
+	float g = 0.f;
+	float b = 0.f;
+	float a = 0.f;
+
+	// Size fields are made available to let the consumer modify the vertex data for optimization
+	float xmin = 0.f;
+	float ymin = 0.f;
+	float xmax = 0.f;
+	float ymax = 0.f;
+	float clip_xmin = 0.f;
+	float clip_xmax = 0.f;
+	float clip_ymin = 0.f;
+	float clip_ymax = 0.f;
+	bool clip = false;
+	bool flip = false;
+	bool vflip = false;
+};
+
+struct Draw2dVertices
+{
+	std::size_t begin_index = 0;
+	std::size_t begin_element = 0;
+	std::size_t elements = 0;
+	Draw2dBlend blend = Draw2dBlend::kModulate;
+	lumpnum_t flat_lump = UINT32_MAX; // LUMPERROR but not loading w_wad.h from this header
+	bool lines = false;
+};
+
+using Draw2dCmd = std::variant<Draw2dPatchQuad, Draw2dVertices>;
+
+Draw2dBlend get_blend_mode(const Draw2dCmd& cmd) noexcept;
+bool is_draw_lines(const Draw2dCmd& cmd) noexcept;
+std::size_t elements(const Draw2dCmd& cmd) noexcept;
+
+struct Draw2dList
+{
+	std::vector<TwodeeVertex> vertices;
+	std::vector<uint16_t> indices;
+	std::vector<Draw2dCmd> cmds;
+
+	static constexpr const std::size_t kMaxVertices = 65536;
+};
+
+class Draw2dQuadBuilder;
+class Draw2dVerticesBuilder;
+
+/// @brief Buffered 2D drawing context
+class Twodee
+{
+	std::vector<Draw2dList> lists_;
+	std::vector<TwodeeVertex> current_verts_;
+	std::vector<uint16_t> current_indices_;
+
+	friend class Draw2dQuadBuilder;
+	friend class Draw2dVerticesBuilder;
+
+public:
+	Twodee();
+	Twodee(const Twodee&);
+	Twodee(Twodee&&) noexcept;
+
+	Twodee& operator=(const Twodee&);
+	Twodee& operator=(Twodee&&) noexcept;
+
+	Draw2dQuadBuilder begin_quad() noexcept;
+	Draw2dVerticesBuilder begin_verts() noexcept;
+
+	typename std::vector<Draw2dList>::iterator begin() noexcept { return lists_.begin(); }
+	typename std::vector<Draw2dList>::iterator end() noexcept { return lists_.end(); }
+	typename std::vector<Draw2dList>::const_iterator begin() const noexcept { return lists_.cbegin(); }
+	typename std::vector<Draw2dList>::const_iterator end() const noexcept { return lists_.cend(); }
+	typename std::vector<Draw2dList>::const_iterator cbegin() const noexcept { return lists_.cbegin(); }
+	typename std::vector<Draw2dList>::const_iterator cend() const noexcept { return lists_.cend(); }
+};
+
+class Draw2dQuadBuilder
+{
+	Draw2dPatchQuad quad_;
+	Twodee& ctx_;
+
+	Draw2dQuadBuilder(Twodee& ctx) : quad_ {}, ctx_ {ctx} {}
+
+	friend class Twodee;
+
+public:
+	Draw2dQuadBuilder(const Draw2dQuadBuilder&) = delete;
+	Draw2dQuadBuilder(Draw2dQuadBuilder&&) = default;
+	Draw2dQuadBuilder& operator=(const Draw2dQuadBuilder&) = delete;
+	Draw2dQuadBuilder& operator=(Draw2dQuadBuilder&&) = default;
+
+	Draw2dQuadBuilder& rect(float x, float y, float w, float h)
+	{
+		quad_.xmin = x;
+		quad_.xmax = x + w;
+		quad_.ymin = y;
+		quad_.ymax = y + h;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& flip(bool flip)
+	{
+		quad_.flip = flip;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& vflip(bool vflip)
+	{
+		quad_.vflip = vflip;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& clip(float xmin, float ymin, float xmax, float ymax)
+	{
+		quad_.clip_xmin = xmin;
+		quad_.clip_ymin = ymin;
+		quad_.clip_xmax = xmax;
+		quad_.clip_ymax = ymax;
+		quad_.clip = true;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& color(float r, float g, float b, float a)
+	{
+		quad_.r = r;
+		quad_.g = g;
+		quad_.b = b;
+		quad_.a = a;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& patch(const patch_t* patch)
+	{
+		quad_.patch = patch;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& blend(Draw2dBlend blend)
+	{
+		quad_.blend = blend;
+		return *this;
+	}
+
+	Draw2dQuadBuilder& colormap(const uint8_t* colormap)
+	{
+		quad_.colormap = colormap;
+		return *this;
+	}
+
+	void done();
+};
+
+class Draw2dVerticesBuilder
+{
+	Draw2dVertices tris_;
+	Twodee& ctx_;
+	std::vector<std::array<float, 4>> verts_;
+	float r_ = 1.f;
+	float g_ = 1.f;
+	float b_ = 1.f;
+	float a_ = 1.f;
+
+	Draw2dVerticesBuilder(Twodee& ctx) : tris_ {}, ctx_ {ctx} {}
+
+	friend class Twodee;
+
+public:
+	Draw2dVerticesBuilder(const Draw2dVerticesBuilder&) = delete;
+	Draw2dVerticesBuilder(Draw2dVerticesBuilder&&) = default;
+	Draw2dVerticesBuilder& operator=(const Draw2dVerticesBuilder&) = delete;
+	Draw2dVerticesBuilder& operator=(Draw2dVerticesBuilder&&) = default;
+
+	Draw2dVerticesBuilder& vert(float x, float y, float u = 0, float v = 0)
+	{
+		verts_.push_back({x, y, u, v});
+		tris_.elements += 1;
+		return *this;
+	}
+
+	Draw2dVerticesBuilder& color(float r, float g, float b, float a)
+	{
+		r_ = r;
+		g_ = g;
+		b_ = b;
+		a_ = a;
+		return *this;
+	}
+
+	Draw2dVerticesBuilder& blend(Draw2dBlend blend)
+	{
+		tris_.blend = blend;
+		return *this;
+	}
+
+	Draw2dVerticesBuilder& lines(bool lines)
+	{
+		tris_.lines = lines;
+		return *this;
+	}
+
+	Draw2dVerticesBuilder& flat(lumpnum_t lump)
+	{
+		tris_.flat_lump = lump;
+		return *this;
+	}
+
+	void done();
+};
+
+inline Draw2dQuadBuilder Twodee::begin_quad() noexcept
+{
+	return Draw2dQuadBuilder(*this);
+}
+
+inline Draw2dVerticesBuilder Twodee::begin_verts() noexcept
+{
+	return Draw2dVerticesBuilder(*this);
+}
+
+} // namespace srb2::hwr2
+
+#endif // __SRB2_HWR2_TWODEE_HPP__
diff --git a/src/i_video_common.cpp b/src/i_video_common.cpp
index 14f5e31ae77fae450bc4dda7fa6cbe01c484df02..040c477c0d9a7de35a8a266276ae90738006593d 100644
--- a/src/i_video_common.cpp
+++ b/src/i_video_common.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "i_video.h"
 
 #include <algorithm>
@@ -7,24 +16,30 @@
 #include <imgui.h>
 
 #include "cxxutil.hpp"
+#include "f_finale.h"
+#include "hwr2/pass_blit_rect.hpp"
 #include "hwr2/pass_imgui.hpp"
+#include "hwr2/pass_manager.hpp"
+#include "hwr2/pass_postprocess.hpp"
+#include "hwr2/pass_resource_managers.hpp"
 #include "hwr2/pass_software.hpp"
+#include "hwr2/pass_twodee.hpp"
+#include "hwr2/twodee.hpp"
 #include "v_video.h"
 
 // KILL THIS WHEN WE KILL OLD OGL SUPPORT PLEASE
-#include "sdl/ogl_sdl.h"
-#include "st_stuff.h" // kill
 #include "d_netcmd.h" // kill
+#include "discord.h"  // kill
 #include "doomstat.h" // kill
-#include "s_sound.h" // kill
-#include "discord.h" // kill
+#include "s_sound.h"  // kill
+#include "sdl/ogl_sdl.h"
+#include "st_stuff.h" // kill
 
 using namespace srb2;
 using namespace srb2::hwr2;
 using namespace srb2::rhi;
 
-static SoftwareBlitPass g_sw_pass;
-static ImguiPass g_imgui_pass;
+static std::shared_ptr<PassManager> g_passmanager;
 
 Handle<Rhi> srb2::sys::g_current_rhi = kNullHandle;
 
@@ -48,8 +63,7 @@ static void finish_legacy_ogl_update()
 		if (cv_ticrate.value)
 			SCR_DisplayTicRate();
 
-		if (cv_showping.value && netgame &&
-				( consoleplayer != serverplayer || ! server_lagless ))
+		if (cv_showping.value && netgame && (consoleplayer != serverplayer || !server_lagless))
 		{
 			if (server_lagless)
 			{
@@ -58,11 +72,8 @@ static void finish_legacy_ogl_update()
 			}
 			else
 			{
-				for (
-						player = 1;
-						player < MAXPLAYERS;
-						player++
-				){
+				for (player = 1; player < MAXPLAYERS; player++)
+				{
 					if (D_IsPlayerHumanAndGaming(player))
 					{
 						SCR_DisplayLocalPing();
@@ -91,6 +102,154 @@ static void finish_legacy_ogl_update()
 }
 #endif
 
+static std::shared_ptr<PassManager> build_pass_manager()
+{
+	std::shared_ptr<PassManager> manager = std::make_shared<PassManager>();
+
+	std::shared_ptr<FramebufferManager> framebuffer_manager = std::make_shared<FramebufferManager>();
+	std::shared_ptr<MainPaletteManager> palette_manager = std::make_shared<MainPaletteManager>();
+	std::shared_ptr<FlatTextureManager> flat_texture_manager = std::make_shared<FlatTextureManager>();
+
+	std::shared_ptr<SoftwarePass> software_pass = std::make_shared<SoftwarePass>();
+	std::shared_ptr<BlitRectPass> blit_sw_pass = std::make_shared<BlitRectPass>(palette_manager, true);
+	std::shared_ptr<TwodeePass> twodee = std::make_shared<TwodeePass>();
+	twodee->flat_manager_ = flat_texture_manager;
+	twodee->data_ = make_twodee_pass_data();
+	twodee->ctx_ = &g_2d;
+	std::shared_ptr<BlitRectPass> pp_simple_blit_pass = std::make_shared<BlitRectPass>(false);
+	std::shared_ptr<PostprocessWipePass> pp_wipe_pass = std::make_shared<PostprocessWipePass>();
+	std::shared_ptr<ImguiPass> imgui_pass = std::make_shared<ImguiPass>();
+	std::shared_ptr<BlitRectPass> final_composite_pass = std::make_shared<BlitRectPass>(true);
+
+	manager->insert("framebuffer_manager", framebuffer_manager);
+	manager->insert("palette_manager", palette_manager);
+	manager->insert("flat_texture_manager", flat_texture_manager);
+
+	manager->insert(
+		"3d_prepare",
+		[framebuffer_manager](PassManager& mgr, Rhi&)
+		{
+			const bool sw_enabled = rendermode == render_soft;
+
+			mgr.set_pass_enabled("software", !g_wipeskiprender && sw_enabled);
+			mgr.set_pass_enabled("blit_sw_prepare", !g_wipeskiprender && sw_enabled);
+			mgr.set_pass_enabled("blit_sw", !g_wipeskiprender && sw_enabled);
+		},
+		[framebuffer_manager](PassManager&, Rhi&)
+		{
+			if (!WipeInAction)
+			{
+				framebuffer_manager->swap_main();
+			}
+		}
+	);
+	manager->insert("software", software_pass);
+	manager->insert(
+		"blit_sw_prepare",
+		[blit_sw_pass, software_pass, framebuffer_manager](PassManager&, Rhi&)
+		{
+			blit_sw_pass->set_texture(software_pass->screen_texture(), vid.width, vid.height);
+			blit_sw_pass->set_output(framebuffer_manager->current_main_color(), vid.width, vid.height, false, false);
+		}
+	);
+	manager->insert("blit_sw", blit_sw_pass);
+
+	manager->insert(
+		"2d_prepare",
+		[twodee, framebuffer_manager](PassManager& mgr, Rhi&)
+		{
+			twodee->output_ = framebuffer_manager->current_main_color();
+			twodee->output_width_ = vid.width;
+			twodee->output_height_ = vid.height;
+		}
+	);
+	manager->insert("2d", twodee);
+
+	manager->insert(
+		"pp_final_prepare",
+		[](PassManager& mgr, Rhi&)
+		{
+			mgr.set_pass_enabled("pp_final_wipe_prepare", WipeInAction);
+			mgr.set_pass_enabled("pp_final_wipe", WipeInAction);
+			mgr.set_pass_enabled("pp_final_wipe_flip", WipeInAction);
+		}
+	);
+	manager->insert(
+		"pp_final_simple_blit_prepare",
+		[pp_simple_blit_pass, framebuffer_manager](PassManager&, Rhi&)
+		{
+			Handle<Texture> color = framebuffer_manager->current_main_color();
+			if (WipeInAction && !g_wipereverse)
+			{
+				// Non-reverse wipes are "fade-outs" from the previous frame.
+				color = framebuffer_manager->previous_main_color();
+			}
+			pp_simple_blit_pass->set_texture(color, vid.width, vid.height);
+			pp_simple_blit_pass
+				->set_output(framebuffer_manager->current_post_color(), vid.width, vid.height, false, true);
+		}
+	);
+	manager->insert("pp_final_simple_blit", pp_simple_blit_pass);
+	manager->insert(
+		"pp_final_simple_blit_flip",
+		[framebuffer_manager](PassManager&, Rhi&) { framebuffer_manager->swap_post(); }
+	);
+	manager->insert(
+		"pp_final_wipe_prepare",
+		[pp_wipe_pass, framebuffer_manager](PassManager&, Rhi&)
+		{
+			pp_wipe_pass->set_source(framebuffer_manager->previous_post_color(), vid.width, vid.height);
+			pp_wipe_pass->set_end(framebuffer_manager->current_main_color());
+			pp_wipe_pass->set_target(framebuffer_manager->current_post_color(), vid.width, vid.height);
+		}
+	);
+	manager->insert("pp_final_wipe", pp_wipe_pass);
+	manager->insert(
+		"pp_final_wipe_flip",
+		[framebuffer_manager](PassManager&, Rhi&) { framebuffer_manager->swap_post(); }
+	);
+
+	manager->insert(
+		"final_composite_prepare",
+		[final_composite_pass, framebuffer_manager](PassManager&, Rhi&)
+		{
+			final_composite_pass->set_texture(framebuffer_manager->previous_post_color(), vid.width, vid.height);
+			final_composite_pass->set_output(kNullHandle, vid.realwidth, vid.realheight, true, true);
+		}
+	);
+	manager->insert("final_composite", final_composite_pass);
+
+	manager->insert("imgui", imgui_pass);
+
+	manager->insert(
+		"present",
+		[](PassManager&, Rhi& rhi) {},
+		[framebuffer_manager](PassManager&, Rhi& rhi)
+		{
+			rhi.present();
+			rhi.finish();
+			framebuffer_manager->reset_post();
+
+			// TODO fix this: it's an ugly hack to work around issues with wipes
+			// Why this works:
+			// - Menus run F_RunWipe which is an inner update loop calling I_FinishUpdate, with this global set
+			// - After exiting F_RunWipe, g_2d should normally be cleared by I_FinishUpdate
+			// - Unfortunately, the menu has already run all its draw calls when exiting F_RunWipe
+			// - That causes a single-frame flash of no 2d content, which is an epilepsy risk.
+			// - By not clearing the 2d context, we are redrawing 2d every frame of the wipe
+			// - This "works" because we draw 2d to the normal color buffer, not the postprocessed screen.
+			// - It does result in the FPS counter being mangled during the wipe though.
+			// - To fix the issues around wipes, wipes need to be a "sub" game state, and eliminate the inner tic loops.
+			if (!WipeInAction)
+			{
+				g_2d = Twodee();
+			}
+		}
+	);
+
+	return manager;
+}
+
 void I_FinishUpdate(void)
 {
 	if (rendermode == render_none)
@@ -112,11 +271,9 @@ void I_FinishUpdate(void)
 	io.DisplaySize.y = vid.realheight;
 	ImGui::NewFrame();
 
-	if (rhi_changed())
+	if (rhi_changed() || !g_passmanager)
 	{
-		// reinitialize passes
-		g_sw_pass = SoftwareBlitPass();
-		g_imgui_pass = ImguiPass();
+		g_passmanager = build_pass_manager();
 	}
 
 	rhi::Rhi* rhi = sys::get_rhi(sys::g_current_rhi);
@@ -127,48 +284,5 @@ void I_FinishUpdate(void)
 		return;
 	}
 
-	// Prepare phase
-	if (rendermode == render_soft)
-	{
-		g_sw_pass.prepass(*rhi);
-	}
-	g_imgui_pass.prepass(*rhi);
-
-	// Transfer phase
-	Handle<TransferContext> tc;
-	tc = rhi->begin_transfer();
-
-	if (rendermode == render_soft)
-	{
-		g_sw_pass.transfer(*rhi, tc);
-	}
-	g_imgui_pass.transfer(*rhi, tc);
-
-	rhi->end_transfer(tc);
-
-	// Graphics phase
-	Handle<GraphicsContext> gc;
-	gc = rhi->begin_graphics();
-
-	// Standard drawing passes...
-	if (rendermode == render_soft)
-	{
-		g_sw_pass.graphics(*rhi, gc);
-	}
-	g_imgui_pass.graphics(*rhi, gc);
-
-	rhi->end_graphics(gc);
-
-	// Postpass phase
-	if (rendermode == render_soft)
-	{
-		g_sw_pass.postpass(*rhi);
-	}
-	g_imgui_pass.postpass(*rhi);
-
-	// Present
-
-	rhi->present();
-
-	rhi->finish();
+	g_passmanager->render(*rhi);
 }
diff --git a/src/k_menudraw.c b/src/k_menudraw.c
index b71179ede039619b5d4c3412d2bb0a01c00b9ebc..a7842372140a882b78634b4ccd7bdc31bc99fab8 100644
--- a/src/k_menudraw.c
+++ b/src/k_menudraw.c
@@ -517,10 +517,7 @@ void M_Drawer(void)
 		}
 		else if (!WipeInAction && currentMenu != &PAUSE_PlaybackMenuDef)
 		{
-			if (rendermode == render_opengl)	// OGL can't handle what SW is doing so let's fake it;
-				V_DrawFadeScreen(122, 3);	// palette index aproximation...
-			else	// Software can keep its unique fade
-				V_DrawCustomFadeScreen("FADEMAP0", 4); // now that's more readable with a faded background (yeah like Quake...)
+			V_DrawFadeScreen(122, 3);
 		}
 
 		if (currentMenu->drawroutine)
@@ -4802,7 +4799,7 @@ static void M_DrawChallengePreview(INT32 x, INT32 y)
 	unlockable_t *ref = NULL;
 	UINT8 *colormap = NULL;
 	UINT16 specialmap = NEXTMAP_INVALID;
-	
+
 	if (challengesmenu.currentunlock >= MAXUNLOCKABLES)
 	{
 		return;
diff --git a/src/r_patch.cpp b/src/r_patch.cpp
index a27e8035c86344f0c112bea23845b9a6427b7c12..443e940a301465845837b926b3813943ae742c79 100644
--- a/src/r_patch.cpp
+++ b/src/r_patch.cpp
@@ -103,6 +103,7 @@ void Patch_Free(patch_t *patch)
 {
 	if (!patch || patch == missingpat)
 		return;
+
 	Patch_FreeData(patch);
 	Z_Free(patch);
 }
diff --git a/src/rhi/gl3_core/gl3_core_rhi.cpp b/src/rhi/gl3_core/gl3_core_rhi.cpp
index 7b9500594421f473de0f6d6056865e5516f5c83e..f98d25d0b3965b0872110f7b3f287151c7f49523 100644
--- a/src/rhi/gl3_core/gl3_core_rhi.cpp
+++ b/src/rhi/gl3_core/gl3_core_rhi.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "gl3_core_rhi.hpp"
 
 #include <memory>
@@ -13,7 +22,7 @@
 using namespace srb2;
 using namespace rhi;
 
-#if 1
+#ifndef NDEBUG
 #define GL_ASSERT                                                                                                      \
 	{                                                                                                                  \
 		GLenum __err = gl_->GetError();                                                                                \
@@ -56,6 +65,11 @@ constexpr std::tuple<GLenum, GLenum, GLuint> map_pixel_data_format(rhi::PixelFor
 		type = GL_UNSIGNED_BYTE;
 		size = 1;
 		break;
+	case rhi::PixelFormat::kRG8:
+		layout = GL_RG;
+		type = GL_UNSIGNED_BYTE;
+		size = 2;
+		break;
 	case rhi::PixelFormat::kRGBA8:
 		layout = GL_RGBA;
 		type = GL_UNSIGNED_BYTE;
@@ -77,6 +91,27 @@ constexpr GLenum map_texture_format(rhi::TextureFormat format)
 		return GL_RGB;
 	case rhi::TextureFormat::kLuminance:
 		return GL_RED;
+	case rhi::TextureFormat::kLuminanceAlpha:
+		return GL_RG;
+	default:
+		return GL_ZERO;
+	}
+}
+
+constexpr GLenum map_internal_texture_format(rhi::TextureFormat format)
+{
+	switch (format)
+	{
+	case rhi::TextureFormat::kRGBA:
+		return GL_RGBA8;
+	case rhi::TextureFormat::kRGB:
+		return GL_RGB8;
+	case rhi::TextureFormat::kLuminance:
+		return GL_R8;
+	case rhi::TextureFormat::kLuminanceAlpha:
+		return GL_RG8;
+	case rhi::TextureFormat::kDepth:
+		return GL_DEPTH_COMPONENT24;
 	default:
 		return GL_ZERO;
 	}
@@ -286,6 +321,27 @@ constexpr const char* map_uniform_attribute_symbol_name(rhi::UniformName name)
 		return "u_projection";
 	case rhi::UniformName::kTexCoord0Transform:
 		return "u_texcoord0_transform";
+	case rhi::UniformName::kSampler0IsIndexedAlpha:
+		return "u_sampler0_is_indexed_alpha";
+	default:
+		return nullptr;
+	}
+}
+
+constexpr const char* map_uniform_enable_define(rhi::UniformName name)
+{
+	switch (name)
+	{
+	case rhi::UniformName::kTime:
+		return "ENABLE_U_TIME";
+	case rhi::UniformName::kProjection:
+		return "ENABLE_U_PROJECTION";
+	case rhi::UniformName::kModelView:
+		return "ENABLE_U_MODELVIEW";
+	case rhi::UniformName::kTexCoord0Transform:
+		return "ENABLE_U_TEXCOORD0_TRANSFORM";
+	case rhi::UniformName::kSampler0IsIndexedAlpha:
+		return "ENABLE_U_SAMPLER0_IS_INDEXED_ALPHA";
 	default:
 		return nullptr;
 	}
@@ -308,6 +364,23 @@ constexpr const char* map_sampler_symbol_name(rhi::SamplerName name)
 	}
 }
 
+constexpr const char* map_sampler_enable_define(rhi::SamplerName name)
+{
+	switch (name)
+	{
+	case rhi::SamplerName::kSampler0:
+		return "ENABLE_S_SAMPLER0";
+	case rhi::SamplerName::kSampler1:
+		return "ENABLE_S_SAMPLER1";
+	case rhi::SamplerName::kSampler2:
+		return "ENABLE_S_SAMPLER2";
+	case rhi::SamplerName::kSampler3:
+		return "ENABLE_S_SAMPLER3";
+	default:
+		return nullptr;
+	}
+}
+
 constexpr GLenum map_vertex_attribute_format(rhi::VertexAttributeFormat format)
 {
 	switch (format)
@@ -423,8 +496,13 @@ rhi::Handle<rhi::Texture> GlCoreRhi::create_texture(const rhi::TextureDesc& desc
 {
 	SRB2_ASSERT(graphics_context_active_ == false);
 
-	GLenum internal_format = map_texture_format(desc.format);
+	GLenum internal_format = map_internal_texture_format(desc.format);
 	SRB2_ASSERT(internal_format != GL_ZERO);
+	GLenum format = GL_RGBA;
+	if (desc.format == TextureFormat::kDepth)
+	{
+		format = GL_DEPTH_COMPONENT;
+	}
 
 	GLuint name = 0;
 	gl_->GenTextures(1, &name);
@@ -439,7 +517,7 @@ rhi::Handle<rhi::Texture> GlCoreRhi::create_texture(const rhi::TextureDesc& desc
 	GL_ASSERT
 	gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
 	GL_ASSERT
-	gl_->TexImage2D(GL_TEXTURE_2D, 0, internal_format, desc.width, desc.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+	gl_->TexImage2D(GL_TEXTURE_2D, 0, internal_format, desc.width, desc.height, 0, format, GL_UNSIGNED_BYTE, nullptr);
 	GL_ASSERT
 
 	GlCoreTexture texture;
@@ -478,13 +556,19 @@ void GlCoreRhi::update_texture(
 	SRB2_ASSERT(texture_slab_.is_valid(texture) == true);
 	auto& t = texture_slab_[texture];
 
+	// Each row of pixels must be on the unpack alignment boundary.
+	// This alignment is not user changeable until OpenGL 4.
+	constexpr const int32_t kUnpackAlignment = 4;
+
 	GLenum format = GL_RGBA;
 	GLenum type = GL_UNSIGNED_BYTE;
 	GLuint size = 0;
 	std::tie(format, type, size) = map_pixel_data_format(data_format);
 	SRB2_ASSERT(format != GL_ZERO && type != GL_ZERO);
 	SRB2_ASSERT(map_texture_format(t.desc.format) == format);
-	SRB2_ASSERT(region.w * region.h * size == data.size_bytes());
+
+	int32_t expected_row_span = (((size * region.w) + kUnpackAlignment - 1) / kUnpackAlignment) * kUnpackAlignment;
+	SRB2_ASSERT(expected_row_span * region.h == data.size_bytes());
 	SRB2_ASSERT(region.x + region.w <= t.desc.width && region.y + region.h <= t.desc.height);
 
 	gl_->ActiveTexture(GL_TEXTURE0);
@@ -740,14 +824,79 @@ rhi::Handle<rhi::Pipeline> GlCoreRhi::create_pipeline(const PipelineDesc& desc)
 					}
 				}
 			}
+			for (auto& uniform_group : desc.uniform_input.enabled_uniforms)
+			{
+				for (auto& uniform : uniform_group)
+				{
+					for (auto const& req_uni_group : reqs.uniforms.uniform_groups)
+					{
+						for (auto const& req_uni : req_uni_group)
+						{
+							if (req_uni.name == uniform && !req_uni.required)
+							{
+								vert_src_processed.append("#define ");
+								vert_src_processed.append(map_uniform_enable_define(uniform));
+								vert_src_processed.append("\n");
+							}
+						}
+					}
+				}
+			}
+		}
+		string_i = new_i + 1;
+	} while (string_i != std::string::npos);
+
+	std::string frag_src_processed;
+	string_i = 0;
+	do
+	{
+		std::string::size_type new_i = frag_src.find('\n', string_i);
+		if (new_i == std::string::npos)
+		{
+			break;
+		}
+		std::string_view line_view(frag_src.c_str() + string_i, new_i - string_i + 1);
+		frag_src_processed.append(line_view);
+		if (line_view.rfind("#version ", 0) == 0)
+		{
+			for (auto& sampler : desc.sampler_input.enabled_samplers)
+			{
+				for (auto const& require_sampler : reqs.samplers.samplers)
+				{
+					if (sampler == require_sampler.name && !require_sampler.required)
+					{
+						frag_src_processed.append("#define ");
+						frag_src_processed.append(map_sampler_enable_define(sampler));
+						frag_src_processed.append("\n");
+					}
+				}
+			}
+			for (auto& uniform_group : desc.uniform_input.enabled_uniforms)
+			{
+				for (auto& uniform : uniform_group)
+				{
+					for (auto const& req_uni_group : reqs.uniforms.uniform_groups)
+					{
+						for (auto const& req_uni : req_uni_group)
+						{
+							if (req_uni.name == uniform && !req_uni.required)
+							{
+								frag_src_processed.append("#define ");
+								frag_src_processed.append(map_uniform_enable_define(uniform));
+								frag_src_processed.append("\n");
+							}
+						}
+					}
+				}
+			}
 		}
 		string_i = new_i + 1;
 	} while (string_i != std::string::npos);
 
 	const char* vert_src_arr[1] = {vert_src_processed.c_str()};
 	const GLint vert_src_arr_lens[1] = {static_cast<GLint>(vert_src_processed.size())};
-	const char* frag_src_arr[1] = {frag_src.c_str()};
-	const GLint frag_src_arr_lens[1] = {static_cast<GLint>(frag_src.size())};
+	const char* frag_src_arr[1] = {frag_src_processed.c_str()};
+	const GLint frag_src_arr_lens[1] = {static_cast<GLint>(frag_src_processed.size())};
 
 	vertex = gl_->CreateShader(GL_VERTEX_SHADER);
 	gl_->ShaderSource(vertex, 1, vert_src_arr, vert_src_arr_lens);
@@ -1380,6 +1529,8 @@ void GlCoreRhi::bind_index_buffer(Handle<GraphicsContext> ctx, Handle<Buffer> bu
 
 	SRB2_ASSERT(ib.desc.type == rhi::BufferType::kIndexBuffer);
 
+	current_index_buffer_ = buffer;
+
 	gl_->BindBuffer(GL_ELEMENT_ARRAY_BUFFER, ib.buffer);
 }
 
@@ -1412,11 +1563,20 @@ void GlCoreRhi::draw(Handle<GraphicsContext> ctx, uint32_t vertex_count, uint32_
 void GlCoreRhi::draw_indexed(Handle<GraphicsContext> ctx, uint32_t index_count, uint32_t first_index)
 {
 	SRB2_ASSERT(graphics_context_active_ == true && graphics_context_generation_ == ctx.generation());
+
+	SRB2_ASSERT(current_index_buffer_ != kNullHandle);
+#ifndef NDEBUG
+	{
+		auto& ib = buffer_slab_[current_index_buffer_];
+		SRB2_ASSERT((index_count + first_index) * 2 + index_buffer_offset_ <= ib.desc.size);
+	}
+#endif
+
 	gl_->DrawElements(
 		map_primitive_mode(current_primitive_type_),
 		index_count,
 		GL_UNSIGNED_SHORT,
-		reinterpret_cast<const void*>(first_index * 2 + index_buffer_offset_)
+		(const void*)((size_t)first_index * 2 + index_buffer_offset_)
 	);
 	GL_ASSERT
 }
diff --git a/src/rhi/gl3_core/gl3_core_rhi.hpp b/src/rhi/gl3_core/gl3_core_rhi.hpp
index fa7997b8b21776c8d4d0dadacbdba106933af86b..b5b43afa1ac05b4d51aac447349967eb4da3a068 100644
--- a/src/rhi/gl3_core/gl3_core_rhi.hpp
+++ b/src/rhi/gl3_core/gl3_core_rhi.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_RHI_GLES2_RHI_HPP__
 #define __SRB2_RHI_GLES2_RHI_HPP__
 
@@ -145,6 +154,8 @@ class GlCoreRhi final : public Rhi
 	Slab<GlCoreUniformSet> uniform_set_slab_;
 	Slab<GlCoreBindingSet> binding_set_slab_;
 
+	Handle<Buffer> current_index_buffer_;
+
 	std::unordered_map<GlCoreFramebufferKey, uint32_t> framebuffers_ {16};
 
 	struct DefaultRenderPassState
diff --git a/src/rhi/gles2/gles2_rhi.cpp b/src/rhi/gles2/gles2_rhi.cpp
index 5c8134eb6637eb9ed764871c0f1c3c25c50360ac..a1eb92acfc2d8e3c1bea2a66254a870ce53d1fac 100644
--- a/src/rhi/gles2/gles2_rhi.cpp
+++ b/src/rhi/gles2/gles2_rhi.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "gles2_rhi.hpp"
 
 #include <memory>
diff --git a/src/rhi/gles2/gles2_rhi.hpp b/src/rhi/gles2/gles2_rhi.hpp
index 9858e770ba3bdc9bdb67ef4f259ee92d0407395b..f912941b4b315c19af33bc3eb6684fba54693fe9 100644
--- a/src/rhi/gles2/gles2_rhi.hpp
+++ b/src/rhi/gles2/gles2_rhi.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_RHI_GLES2_RHI_HPP__
 #define __SRB2_RHI_GLES2_RHI_HPP__
 
diff --git a/src/rhi/handle.hpp b/src/rhi/handle.hpp
index bda2928faa1411611acf12e46eb450b58a136a99..282a924dabad57ebac37e4fa35146cdd53ad2d22 100644
--- a/src/rhi/handle.hpp
+++ b/src/rhi/handle.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_RHI_HANDLE_HPP__
 #define __SRB2_RHI_HANDLE_HPP__
 
diff --git a/src/rhi/rhi.cpp b/src/rhi/rhi.cpp
index c63282b9570f078479e167d2b723dc4a6faa695c..7e166246a384b8407f00b13301b6e144490f29b5 100644
--- a/src/rhi/rhi.cpp
+++ b/src/rhi/rhi.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "rhi.hpp"
 
 #include <exception>
@@ -14,8 +23,9 @@ const ProgramRequirements srb2::rhi::kProgramRequirementsUnshaded = {
 		 ProgramVertexInput {VertexAttributeName::kTexCoord0, VertexAttributeFormat::kFloat2, false},
 		 ProgramVertexInput {VertexAttributeName::kColor, VertexAttributeFormat::kFloat4, false}}},
 	ProgramUniformRequirements {
-		{{{UniformName::kProjection}}, {{UniformName::kModelView, UniformName::kTexCoord0Transform}}}},
-	ProgramSamplerRequirements {{ProgramSamplerInput {SamplerName::kSampler0, true}}}};
+		{{{{UniformName::kProjection, true}}},
+		 {{{UniformName::kModelView, true}, {UniformName::kTexCoord0Transform, true}}}}},
+	ProgramSamplerRequirements {{{SamplerName::kSampler0, true}}}};
 
 const ProgramRequirements srb2::rhi::kProgramRequirementsUnshadedPaletted = {
 	ProgramVertexInputRequirements {
@@ -23,9 +33,19 @@ const ProgramRequirements srb2::rhi::kProgramRequirementsUnshadedPaletted = {
 		 ProgramVertexInput {VertexAttributeName::kTexCoord0, VertexAttributeFormat::kFloat2, false},
 		 ProgramVertexInput {VertexAttributeName::kColor, VertexAttributeFormat::kFloat4, false}}},
 	ProgramUniformRequirements {
-		{{{UniformName::kProjection}}, {{UniformName::kModelView, UniformName::kTexCoord0Transform}}}},
+		{{{{UniformName::kProjection, true}}},
+		 {{{UniformName::kModelView, true},
+		   {UniformName::kTexCoord0Transform, true},
+		   {UniformName::kSampler0IsIndexedAlpha, false}}}}},
 	ProgramSamplerRequirements {
-		{ProgramSamplerInput {SamplerName::kSampler0, true}, ProgramSamplerInput {SamplerName::kSampler1, true}}}};
+		{{SamplerName::kSampler0, true}, {SamplerName::kSampler1, true}, {SamplerName::kSampler2, false}}}};
+
+const ProgramRequirements srb2::rhi::kProgramRequirementsPostprocessWipe = {
+	ProgramVertexInputRequirements {
+		{ProgramVertexInput {VertexAttributeName::kPosition, VertexAttributeFormat::kFloat3, true},
+		 ProgramVertexInput {VertexAttributeName::kTexCoord0, VertexAttributeFormat::kFloat2, true}}},
+	ProgramUniformRequirements {{{{{UniformName::kProjection, true}, {UniformName::kModelView, true}}}}},
+	ProgramSamplerRequirements {{{SamplerName::kSampler0, true}, {SamplerName::kSampler1, true}}}};
 
 const ProgramRequirements& rhi::program_requirements_for_program(PipelineProgram program) noexcept
 {
@@ -35,6 +55,8 @@ const ProgramRequirements& rhi::program_requirements_for_program(PipelineProgram
 		return kProgramRequirementsUnshaded;
 	case PipelineProgram::kUnshadedPaletted:
 		return kProgramRequirementsUnshadedPaletted;
+	case PipelineProgram::kPostprocessWipe:
+		return kProgramRequirementsPostprocessWipe;
 	default:
 		std::terminate();
 	}
diff --git a/src/rhi/rhi.hpp b/src/rhi/rhi.hpp
index a44423aee482cd17cccc2041681fa6d3fae6a924..43659fe402522d65b55e70a0a688809a13f7fe95 100644
--- a/src/rhi/rhi.hpp
+++ b/src/rhi/rhi.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_RHI_RHI_HPP__
 #define __SRB2_RHI_RHI_HPP__
 
@@ -63,6 +72,7 @@ enum class UniformFormat
 enum class PixelFormat
 {
 	kR8,
+	kRG8,
 	kRGBA8,
 	kDepth16,
 	kStencil8
@@ -71,8 +81,10 @@ enum class PixelFormat
 enum class TextureFormat
 {
 	kLuminance,
+	kLuminanceAlpha,
 	kRGB,
-	kRGBA
+	kRGBA,
+	kDepth
 };
 
 enum class CompareFunc
@@ -152,7 +164,8 @@ enum class AttachmentStoreOp
 enum class PipelineProgram
 {
 	kUnshaded,
-	kUnshadedPaletted
+	kUnshadedPaletted,
+	kPostprocessWipe
 };
 
 enum class BufferType
@@ -181,7 +194,8 @@ enum class UniformName
 	kTime,
 	kModelView,
 	kProjection,
-	kTexCoord0Transform
+	kTexCoord0Transform,
+	kSampler0IsIndexedAlpha
 };
 
 enum class SamplerName
@@ -237,12 +251,12 @@ struct ProgramVertexInputRequirements
 
 struct ProgramUniformRequirements
 {
-	srb2::StaticVec<srb2::StaticVec<UniformName, 16>, 4> uniform_groups;
+	srb2::StaticVec<srb2::StaticVec<ProgramUniformInput, 16>, 4> uniform_groups;
 };
 
 struct ProgramSamplerRequirements
 {
-	std::array<std::optional<ProgramSamplerInput>, kMaxSamplers> samplers;
+	srb2::StaticVec<ProgramSamplerInput, kMaxSamplers> samplers;
 };
 
 struct ProgramRequirements
@@ -254,6 +268,7 @@ struct ProgramRequirements
 
 extern const ProgramRequirements kProgramRequirementsUnshaded;
 extern const ProgramRequirements kProgramRequirementsUnshadedPaletted;
+extern const ProgramRequirements kProgramRequirementsPostprocessWipe;
 
 const ProgramRequirements& program_requirements_for_program(PipelineProgram program) noexcept;
 
@@ -288,6 +303,8 @@ inline constexpr const UniformFormat uniform_format(UniformName name) noexcept
 		return UniformFormat::kMat4;
 	case UniformName::kTexCoord0Transform:
 		return UniformFormat::kMat3;
+	case UniformName::kSampler0IsIndexedAlpha:
+		return UniformFormat::kInt;
 	default:
 		return UniformFormat::kFloat;
 	}
@@ -309,8 +326,8 @@ struct VertexAttributeLayoutDesc
 
 struct VertexInputDesc
 {
-	std::vector<VertexBufferLayoutDesc> buffer_layouts;
-	std::vector<VertexAttributeLayoutDesc> attr_layouts;
+	srb2::StaticVec<VertexBufferLayoutDesc, 4> buffer_layouts;
+	srb2::StaticVec<VertexAttributeLayoutDesc, 8> attr_layouts;
 };
 
 struct UniformInputDesc
@@ -489,6 +506,9 @@ struct GraphicsContext
 {
 };
 
+/// @brief The unpack alignment of a row span when uploading pixels to the device.
+constexpr const std::size_t kPixelRowUnpackAlignment = 4;
+
 /// @brief An active handle to a rendering device.
 struct Rhi
 {
diff --git a/src/sdl/i_video.cpp b/src/sdl/i_video.cpp
index bf5deb873137924bd52c290df2b62f7f4a12bcad..d86f4a4b4b018d0fa1ae6c7499ac20f0fa421d98 100644
--- a/src/sdl/i_video.cpp
+++ b/src/sdl/i_video.cpp
@@ -231,7 +231,11 @@ static void SDLSetMode(INT32 width, INT32 height, SDL_bool fullscreen, SDL_bool
 	{
 		OglSdlSurface(vid.width, vid.height);
 	}
+	else
 #endif
+	{
+		SDL_GL_SetSwapInterval(cv_vidwait.value ? 1 : 0);
+	}
 
 	SDL_GetWindowSize(window, &width, &height);
 	vid.realwidth = static_cast<uint32_t>(width);
diff --git a/src/sdl/rhi_gl3_core_platform.cpp b/src/sdl/rhi_gl3_core_platform.cpp
index d8c0cde337f2524d0f7be7092165573ff5dbdef9..5de7eef70095a961a7893fe11720abfa33a8576e 100644
--- a/src/sdl/rhi_gl3_core_platform.cpp
+++ b/src/sdl/rhi_gl3_core_platform.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "rhi_gl3_core_platform.hpp"
 
 #include <SDL.h>
@@ -33,6 +42,10 @@ std::tuple<std::string, std::string> SdlGlCorePlatform::find_shader_sources(rhi:
 		vertex_lump_name = "rhi_glcore_vertex_unshadedpaletted";
 		fragment_lump_name = "rhi_glcore_fragment_unshadedpaletted";
 		break;
+	case rhi::PipelineProgram::kPostprocessWipe:
+		vertex_lump_name = "rhi_glcore_vertex_postprocesswipe";
+		fragment_lump_name = "rhi_glcore_fragment_postprocesswipe";
+		break;
 	default:
 		std::terminate();
 	}
diff --git a/src/sdl/rhi_gl3_core_platform.hpp b/src/sdl/rhi_gl3_core_platform.hpp
index 0c0f6f4f3442f4995a0f11f57ea2b19e0b50792f..9522e4ba4b1b887496439c337f4b88855b8d19a9 100644
--- a/src/sdl/rhi_gl3_core_platform.hpp
+++ b/src/sdl/rhi_gl3_core_platform.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_SDL_RHI_GLES2_PLATFORM_HPP__
 #define __SRB2_SDL_RHI_GLES2_PLATFORM_HPP__
 
diff --git a/src/sdl/rhi_gles2_platform.cpp b/src/sdl/rhi_gles2_platform.cpp
index d91a3d2bfbec231addbcae829c97fe5630a51fbd..edf5fe2016d1e01e15bcd6617282be3a32a02f4f 100644
--- a/src/sdl/rhi_gles2_platform.cpp
+++ b/src/sdl/rhi_gles2_platform.cpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #include "rhi_gles2_platform.hpp"
 
 #include <SDL.h>
diff --git a/src/sdl/rhi_gles2_platform.hpp b/src/sdl/rhi_gles2_platform.hpp
index 19970d8f19b8118aec6c6baebbff7c607b81d748..b434c9c2ea15000ba816240c3e86f03054c8949e 100644
--- a/src/sdl/rhi_gles2_platform.hpp
+++ b/src/sdl/rhi_gles2_platform.hpp
@@ -1,3 +1,12 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2023 by Ronald "Eidolon" Kinard
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+
 #ifndef __SRB2_SDL_RHI_GLES2_PLATFORM_HPP__
 #define __SRB2_SDL_RHI_GLES2_PLATFORM_HPP__
 
diff --git a/src/v_video.cpp b/src/v_video.cpp
index 779a3a6c60239511445a6e04e2693cb66763dd53..4e1f0d0f721fa3eac7fca141c8a0b50b479528df 100644
--- a/src/v_video.cpp
+++ b/src/v_video.cpp
@@ -41,6 +41,8 @@
 #include "k_boss.h"
 #include "i_time.h"
 
+using namespace srb2;
+
 // Each screen is [vid.width*vid.height];
 UINT8 *screens[5];
 // screens[0] = main display window
@@ -97,8 +99,12 @@ RGBA_t *pLocalPalette = NULL;
 RGBA_t *pMasterPalette = NULL;
 RGBA_t *pGammaCorrectedPalette = NULL;
 
+hwr2::Twodee srb2::g_2d;
+
 static size_t currentPaletteSize;
 
+static UINT8 softwaretranstohwr[11]    = {  0, 25, 51, 76,102,127,153,178,204,229,255};
+
 /*
 The following was an extremely helpful resource when developing my Colour Cube LUT.
 http://http.developer.nvidia.com/GPUGems2/gpugems2_chapter24.html
@@ -650,7 +656,7 @@ void V_AdjustXYWithSnap(INT32 *x, INT32 *y, UINT32 options, INT32 dupx, INT32 du
 	}
 }
 
-static cliprect_t cliprect;
+static cliprect_t cliprect = {0};
 
 const cliprect_t *V_GetClipRect(void)
 {
@@ -771,16 +777,11 @@ static inline UINT8 transmappedpdraw(const UINT8 *dest, const UINT8 *source, fix
 // Draws a patch scaled to arbitrary size.
 void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vscale, INT32 scrn, patch_t *patch, const UINT8 *colormap)
 {
-	UINT8 (*patchdrawfunc)(const UINT8*, const UINT8*, fixed_t);
 	UINT32 alphalevel, blendmode;
 
-	fixed_t col, ofs, colfrac, rowfrac, fdup, vdup;
+	fixed_t vdup;
 	INT32 dupx, dupy;
-	const column_t *column;
-	UINT8 *desttop, *dest, *deststart, *destend;
-	const UINT8 *source, *deststop;
 	fixed_t pwidth; // patch width
-	fixed_t offx = 0; // x offset
 
 	const cliprect_t *clip = V_GetClipRect();
 
@@ -796,8 +797,6 @@ void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vsca
 	}
 #endif
 
-	patchdrawfunc = standardpdraw;
-
 	if ((blendmode = ((scrn & V_BLENDMASK) >> V_BLENDSHIFT)))
 		blendmode++; // realign to constants
 	if ((alphalevel = ((scrn & V_ALPHAMASK) >> V_ALPHASHIFT)))
@@ -812,15 +811,6 @@ void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vsca
 		if (alphalevel >= 10) // Still inelegible to render?
 			return;
 	}
-	if ((v_translevel = R_GetBlendTable(blendmode, alphalevel)))
-		patchdrawfunc = translucentpdraw;
-
-	v_colormap = NULL;
-	if (colormap)
-	{
-		v_colormap = colormap;
-		patchdrawfunc = (v_translevel) ? transmappedpdraw : mappedpdraw;
-	}
 
 	dupx = vid.dupx;
 	dupy = vid.dupy;
@@ -843,11 +833,9 @@ void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vsca
 
 	// only use one dup, to avoid stretching (har har)
 	dupx = dupy = (dupx < dupy ? dupx : dupy);
-	fdup = vdup = FixedMul(dupx<<FRACBITS, pscale);
+	vdup = FixedMul(dupx<<FRACBITS, pscale);
 	if (vscale != pscale)
 		vdup = FixedMul(dupx<<FRACBITS, vscale);
-	colfrac = FixedDiv(FRACUNIT, fdup);
-	rowfrac = FixedDiv(FRACUNIT, vdup);
 
 	{
 		fixed_t offsetx = 0, offsety = 0;
@@ -869,18 +857,10 @@ void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vsca
 		y -= offsety;
 	}
 
-	desttop = screens[scrn&V_SCREENMASK];
-
-	if (!desttop)
-		return;
-
-	deststop = desttop + vid.rowbytes * vid.height;
-
 	if (scrn & V_NOSCALESTART)
 	{
 		x >>= FRACBITS;
 		y >>= FRACBITS;
-		desttop += (y*vid.width) + x;
 	}
 	else
 	{
@@ -894,8 +874,6 @@ void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vsca
 		{
 			V_AdjustXYWithSnap(&x, &y, scrn, dupx, dupy);
 		}
-
-		desttop += (y*vid.width) + x;
 	}
 
 	if (pscale != FRACUNIT) // scale width properly
@@ -908,104 +886,73 @@ void V_DrawStretchyFixedPatch(fixed_t x, fixed_t y, fixed_t pscale, fixed_t vsca
 	else
 		pwidth = patch->width * dupx;
 
-	deststart = desttop;
-	destend = desttop + pwidth;
+	float fdupy = FIXED_TO_FLOAT(vdup);
 
-	for (col = 0; (col>>FRACBITS) < patch->width; col += colfrac, ++offx, desttop++)
-	{
-		INT32 topdelta, prevdelta = -1;
+	float fx = x;
+	float fy = y;
+	float fx2 = fx + pwidth;
+	float fy2 = fy + static_cast<float>(patch->height) * fdupy;
+	float falpha = 1.f;
+	float umin = 0.f;
+	float umax = 1.f;
+	float vmin = 0.f;
+	float vmax = 1.f;
 
-		if (scrn & V_FLIP) // offx is measured from right edge instead of left
-		{
-			if (x+pwidth-offx < (clip ? clip->left : 0)) // don't draw off the left of the screen (WRAP PREVENTION)
-				break;
-			if (x+pwidth-offx >= (clip ? clip->right : vid.width)) // don't draw off the right of the screen (WRAP PREVENTION)
-				continue;
-		}
-		else
-		{
-			if (x+offx < (clip ? clip->left : 0)) // don't draw off the left of the screen (WRAP PREVENTION)
-				continue;
-			if (x+offx >= (clip ? clip->right : vid.width)) // don't draw off the right of the screen (WRAP PREVENTION)
-				break;
-		}
-
-		column = (const column_t *)((const UINT8 *)(patch->columns) + (patch->columnofs[col>>FRACBITS]));
-
-		while (column->topdelta != 0xff)
-		{
-			fixed_t offy = 0;
-
-			topdelta = column->topdelta;
-			if (topdelta <= prevdelta)
-				topdelta += prevdelta;
-			prevdelta = topdelta;
-			source = (const UINT8 *)(column) + 3;
-
-			dest = desttop;
-			if (scrn & V_FLIP)
-				dest = deststart + (destend - dest);
-			topdelta = FixedInt(FixedMul(topdelta << FRACBITS, vdup));
-			dest += topdelta * vid.width;
-
-			if (scrn & V_VFLIP)
-			{
-				for (ofs = (column->length << FRACBITS)-1; dest < deststop && ofs >= 0; ofs -= rowfrac, ++offy)
-				{
-					if (clip != NULL)
-					{
-						const INT32 cy = y + topdelta - offy;
-
-						if (cy < clip->top) // don't draw off the top of the clip rect
-						{
-							dest += vid.width;
-							continue;
-						}
-
-						if (cy >= clip->bottom) // don't draw off the bottom of the clip rect
-						{
-							dest += vid.width;
-							continue;
-						}
-					}
-
-					if (dest >= screens[scrn&V_SCREENMASK]) // don't draw off the top of the screen (CRASH PREVENTION)
-						*dest = patchdrawfunc(dest, source, ofs);
+	// flip UVs
+	if (scrn & V_FLIP)
+	{
+		umin = 1.f - umin;
+		umax = 1.f - umax;
+	}
+	if (scrn & V_VFLIP)
+	{
+		vmin = 1.f - vmin;
+		vmax = 1.f - vmax;
+	}
 
-					dest += vid.width;
-				}
-			}
-			else
-			{
-				for (ofs = 0; dest < deststop && ofs < (column->length << FRACBITS); ofs += rowfrac, ++offy)
-				{
-					if (clip != NULL)
-					{
-						const INT32 cy = y + topdelta + offy;
-
-						if (cy < clip->top) // don't draw off the top of the clip rect
-						{
-							dest += vid.width;
-							continue;
-						}
-
-						if (cy >= clip->bottom) // don't draw off the bottom of the clip rect
-						{
-							dest += vid.width;
-							continue;
-						}
-					}
+	if (alphalevel > 0 && alphalevel <= 10)
+	{
+		falpha = (10 - alphalevel) / 10.f;
+	}
+	hwr2::Draw2dBlend blend = hwr2::Draw2dBlend::kModulate;
+	switch (blendmode)
+	{
+	case AST_MODULATE:
+		blend = hwr2::Draw2dBlend::kModulate;
+		break;
+	case AST_ADD:
+		blend = hwr2::Draw2dBlend::kAdditive;
+		break;
 
-					if (dest >= screens[scrn&V_SCREENMASK]) // don't draw off the top of the screen (CRASH PREVENTION)
-						*dest = patchdrawfunc(dest, source, ofs);
+	// Note: SRB2 has these blend modes flipped compared to GL and Vulkan.
+	// SRB2's Subtract is Dst - Src. OpenGL is Src - Dst. And vice versa for reverse.
+	// Twodee will use the GL definitions.
+	case AST_SUBTRACT:
+		blend = hwr2::Draw2dBlend::kReverseSubtractive;
+		break;
+	case AST_REVERSESUBTRACT:
+		blend = hwr2::Draw2dBlend::kSubtractive;
+		break;
+	default:
+		blend = hwr2::Draw2dBlend::kModulate;
+		break;
+	}
 
-					dest += vid.width;
-				}
-			}
+	auto builder = g_2d.begin_quad();
+	builder
+		.patch(patch)
+		.rect(fx, fy, fx2 - fx, fy2 - fy)
+		.flip((scrn & V_FLIP) > 0)
+		.vflip((scrn & V_VFLIP) > 0)
+		.color(1, 1, 1, falpha)
+		.blend(blend)
+		.colormap(colormap);
 
-			column = (const column_t *)((const UINT8 *)column + column->length + 4);
-		}
+	if (clip && clip->enabled)
+	{
+		builder.clip(clip->left, clip->top, clip->right, clip->bottom);
 	}
+	builder.done();
 }
 
 // Draws a patch cropped and scaled to arbitrary size.
@@ -1067,9 +1014,6 @@ void V_DrawBlock(INT32 x, INT32 y, INT32 scrn, INT32 width, INT32 height, const
 //
 void V_DrawFill(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c)
 {
-	UINT8 *dest;
-	const UINT8 *deststop;
-
 	if (rendermode == render_none)
 		return;
 
@@ -1122,13 +1066,18 @@ void V_DrawFill(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c)
 	if (y + h > vid.height)
 		h = vid.height - y;
 
-	dest = screens[0] + y*vid.width + x;
-	deststop = screens[0] + vid.rowbytes * vid.height;
-
 	c &= 255;
 
-	for (;(--h >= 0) && dest < deststop; dest += vid.width)
-		memset(dest, c, w * vid.bpp);
+	RGBA_t color = pMasterPalette[c];
+	UINT8 r = (color.rgba & 0xFF);
+	UINT8 g = (color.rgba & 0xFF00) >> 8;
+	UINT8 b = (color.rgba & 0xFF0000) >> 16;
+
+	g_2d.begin_quad()
+		.patch(nullptr)
+		.color(r / 255.f, g / 255.f, b / 255.f, 1.f)
+		.rect(x, y, w, h)
+		.done();
 }
 
 #ifdef HWRENDER
@@ -1169,10 +1118,6 @@ static UINT32 V_GetHWConsBackColor(void)
 
 void V_DrawFillConsoleMap(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c)
 {
-	UINT8 *dest;
-	const UINT8 *deststop;
-	INT32 u;
-	UINT8 *fadetable;
 	UINT32 alphalevel = 0;
 
 	if (rendermode == render_none)
@@ -1231,37 +1176,18 @@ void V_DrawFillConsoleMap(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c)
 	if (y + h > vid.height)
 		h = vid.height-y;
 
-	dest = screens[0] + y*vid.width + x;
-	deststop = screens[0] + vid.rowbytes * vid.height;
-
 	c &= 255;
 
-	// Jimita (12-04-2018)
-	if (alphalevel)
-	{
-		fadetable = R_GetTranslucencyTable(alphalevel) + (c*256);
-		for (;(--h >= 0) && dest < deststop; dest += vid.width)
-		{
-			u = 0;
-			while (u < w)
-			{
-				dest[u] = fadetable[consolebgmap[dest[u]]];
-				u++;
-			}
-		}
-	}
-	else
-	{
-		for (;(--h >= 0) && dest < deststop; dest += vid.width)
-		{
-			u = 0;
-			while (u < w)
-			{
-				dest[u] = consolebgmap[dest[u]];
-				u++;
-			}
-		}
-	}
+	UINT32 hwcolor = V_GetHWConsBackColor();
+	float r = ((hwcolor & 0xFF000000) >> 24) / 255.f;
+	float g = ((hwcolor & 0xFF0000) >> 16) / 255.f;
+	float b = ((hwcolor & 0xFF00) >> 8) / 255.f;
+	float a = 0.5f; // alphalevel is unused in GL??
+	g_2d.begin_quad()
+		.rect(x, y, w, h)
+		.blend(hwr2::Draw2dBlend::kModulate)
+		.color(r, g, b, a)
+		.done();
 }
 
 //
@@ -1273,9 +1199,7 @@ void V_DrawFillConsoleMap(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c)
 //
 void V_DrawDiag(INT32 x, INT32 y, INT32 wh, INT32 c)
 {
-	UINT8 *dest;
-	const UINT8 *deststop;
-	INT32 w, h, wait = 0;
+	INT32 w, h;
 
 	if (rendermode == render_none)
 		return;
@@ -1321,7 +1245,6 @@ void V_DrawDiag(INT32 x, INT32 y, INT32 wh, INT32 c)
 		return; // zero width/height wouldn't draw anything
 	if (x + w > vid.width)
 	{
-		wait = w - (vid.width - x);
 		w = vid.width - x;
 	}
 	if (y + w > vid.height)
@@ -1330,18 +1253,23 @@ void V_DrawDiag(INT32 x, INT32 y, INT32 wh, INT32 c)
 	if (h > w)
 		h = w;
 
-	dest = screens[0] + y*vid.width + x;
-	deststop = screens[0] + vid.rowbytes * vid.height;
-
 	c &= 255;
 
-	for (;(--h >= 0) && dest < deststop; dest += vid.width)
 	{
-		memset(dest, c, w * vid.bpp);
-		if (wait)
-			wait--;
-		else
-			w--;
+		auto builder = g_2d.begin_verts();
+
+		const RGBA_t color = pMasterPalette[c];
+		const float r = ((color.rgba & 0xFF000000) >> 24) / 255.f;
+		const float g = ((color.rgba & 0xFF0000) >> 16) / 255.f;
+		const float b = ((color.rgba & 0xFF00) >> 8) / 255.f;
+		const float a = 1.f;
+		builder.color(r, g, b, a);
+
+		builder
+			.vert(x, y)
+			.vert(x + wh, y + wh)
+			.vert(x, y + wh)
+			.done();
 	}
 }
 
@@ -1355,11 +1283,6 @@ void V_DrawDiag(INT32 x, INT32 y, INT32 wh, INT32 c)
 //
 void V_DrawFadeFill(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c, UINT16 color, UINT8 strength)
 {
-	UINT8 *dest;
-	const UINT8 *deststop;
-	INT32 u;
-	UINT8 *fadetable;
-
 	if (rendermode == render_none)
 		return;
 
@@ -1403,23 +1326,42 @@ void V_DrawFadeFill(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c, UINT16 color, U
 	if (y + h > vid.height)
 		h = vid.height-y;
 
-	dest = screens[0] + y*vid.width + x;
-	deststop = screens[0] + vid.rowbytes * vid.height;
+	float r;
+	float g;
+	float b;
+	float a;
+	hwr2::Draw2dBlend blendmode;
 
-	c &= 255;
+	if (color & 0xFF00)
+	{
+		// Historical COLORMAP fade
+		// In Ring Racers this is a Mega Drive style per-channel fade (though it'd probably be cool in SRB2 too)
+		// HWR2 will implement as a rev-subtractive rect because colormaps aren't possible in hardware
+		float fstrength = std::clamp(strength / 31.f, 0.f, 1.f);
+		r = std::clamp((fstrength - (0.f / 3.f)) * 3.f, 0.f, 1.f);
+		g = std::clamp((fstrength - (1.f / 3.f)) * 3.f, 0.f, 1.f);
+		b = std::clamp((fstrength - (2.f / 3.f)) * 3.f, 0.f, 1.f);
+		a = 1;
 
-	fadetable = ((color & 0xFF00) // Color is not palette index?
-		? ((UINT8 *)colormaps + strength*256) // Do COLORMAP fade.
-		: ((UINT8 *)R_GetTranslucencyTable((9-strength)+1) + color*256)); // Else, do TRANSMAP** fade.
-	for (;(--h >= 0) && dest < deststop; dest += vid.width)
+		blendmode = hwr2::Draw2dBlend::kReverseSubtractive;
+	}
+	else
 	{
-		u = 0;
-		while (u < w)
-		{
-			dest[u] = fadetable[dest[u]];
-			u++;
-		}
+		// Historically TRANSMAP fade
+		// This is done by modulative (transparent) blend to the given palette color.
+		byteColor_t bc = V_GetColor(color).s;
+		r = bc.red / 255.f;
+		g = bc.green / 255.f;
+		b = bc.blue / 255.f;
+		a = softwaretranstohwr[std::clamp(static_cast<int>(strength), 0, 10)] / 255.f;
+		blendmode = hwr2::Draw2dBlend::kModulate;
 	}
+
+	g_2d.begin_quad()
+		.blend(blendmode)
+		.color(r, g, b, a)
+		.rect(x, y, w, h)
+		.done();
 }
 
 //
@@ -1427,11 +1369,10 @@ void V_DrawFadeFill(INT32 x, INT32 y, INT32 w, INT32 h, INT32 c, UINT16 color, U
 //
 void V_DrawFlatFill(INT32 x, INT32 y, INT32 w, INT32 h, lumpnum_t flatnum)
 {
-	INT32 u, v, dupx, dupy;
-	fixed_t dx, dy, xfrac, yfrac;
-	const UINT8 *src, *deststop;
-	UINT8 *flat, *dest;
-	size_t size, lflatsize, flatshift;
+	INT32 dupx;
+	INT32 dupy;
+	size_t size;
+	size_t lflatsize;
 
 #ifdef HWRENDER
 	if (rendermode == render_opengl)
@@ -1440,89 +1381,52 @@ void V_DrawFlatFill(INT32 x, INT32 y, INT32 w, INT32 h, lumpnum_t flatnum)
 		return;
 	}
 #endif
-
 	size = W_LumpLength(flatnum);
 
 	switch (size)
 	{
 		case 4194304: // 2048x2048 lump
 			lflatsize = 2048;
-			flatshift = 11;
 			break;
 		case 1048576: // 1024x1024 lump
 			lflatsize = 1024;
-			flatshift = 10;
 			break;
 		case 262144:// 512x512 lump
 			lflatsize = 512;
-			flatshift = 9;
 			break;
 		case 65536: // 256x256 lump
 			lflatsize = 256;
-			flatshift = 8;
 			break;
 		case 16384: // 128x128 lump
 			lflatsize = 128;
-			flatshift = 7;
 			break;
 		case 1024: // 32x32 lump
 			lflatsize = 32;
-			flatshift = 5;
 			break;
 		case 256: // 16x16 lump
 			lflatsize = 16;
-			flatshift = 4;
 			break;
 		case 64: // 8x8 lump
 			lflatsize = 8;
-			flatshift = 3;
 			break;
 		default: // 64x64 lump
 			lflatsize = 64;
-			flatshift = 6;
 			break;
 	}
 
-	flat = static_cast<UINT8*>(W_CacheLumpNum(flatnum, PU_CACHE));
+	float fsize = lflatsize;
 
 	dupx = dupy = (vid.dupx < vid.dupy ? vid.dupx : vid.dupy);
 
-	dest = screens[0] + y*dupy*vid.width + x*dupx;
-	deststop = screens[0] + vid.rowbytes * vid.height;
-
-	// from V_DrawScaledPatch
-	if (vid.width != BASEVIDWIDTH * dupx)
-	{
-		// dupx adjustments pretend that screen width is BASEVIDWIDTH * dupx,
-		// so center this imaginary screen
-		dest += (vid.width - (BASEVIDWIDTH * dupx)) / 2;
-	}
-	if (vid.height != BASEVIDHEIGHT * dupy)
-	{
-		// same thing here
-		dest += (vid.height - (BASEVIDHEIGHT * dupy)) * vid.width / 2;
-	}
-
-	w *= dupx;
-	h *= dupy;
-
-	dx = FixedDiv(FRACUNIT, dupx<<(FRACBITS-2));
-	dy = FixedDiv(FRACUNIT, dupy<<(FRACBITS-2));
-
-	yfrac = 0;
-	for (v = 0; v < h; v++, dest += vid.width)
-	{
-		xfrac = 0;
-		src = flat + (((yfrac>>FRACBITS) & (lflatsize - 1)) << flatshift);
-		for (u = 0; u < w; u++)
-		{
-			if (&dest[u] > deststop)
-				return;
-			dest[u] = src[(xfrac>>FRACBITS)&(lflatsize-1)];
-			xfrac += dx;
-		}
-		yfrac += dy;
-	}
+	g_2d.begin_verts()
+		.flat(flatnum)
+		.vert(x * dupx, y * dupy, 0, 0)
+		.vert(x * dupx + w * dupx, y * dupy, w / fsize, 0)
+		.vert(x * dupx + w * dupx, y * dupy + h * dupy, w / fsize, h / fsize)
+		.vert(x * dupx, y * dupy, 0, 0)
+		.vert(x * dupx + w * dupx, y * dupy + h * dupy, w / fsize, h / fsize)
+		.vert(x * dupx, y * dupy + h * dupy, 0, h / fsize)
+		.done();
 }
 
 //
@@ -1619,21 +1523,42 @@ void V_DrawFadeScreen(UINT16 color, UINT8 strength)
 	}
 #endif
 
+	float r;
+	float g;
+	float b;
+	float a;
+	hwr2::Draw2dBlend blendmode;
+
+	if (color & 0xFF00)
 	{
-		const UINT8 *fadetable =
-			(color > 0xFFF0) // Grab a specific colormap palette?
-			? R_GetTranslationColormap(color | 0xFFFF0000, static_cast<skincolornum_t>(strength), GTC_CACHE)
-			: ((color & 0xFF00) // Color is not palette index?
-			? ((UINT8 *)colormaps + strength*256) // Do COLORMAP fade.
-			: ((UINT8 *)R_GetTranslucencyTable((9-strength)+1) + color*256)); // Else, do TRANSMAP** fade.
-		const UINT8 *deststop = screens[0] + vid.rowbytes * vid.height;
-		UINT8 *buf = screens[0];
+		// Historical COLORMAP fade
+		// In Ring Racers this is a Mega Drive style per-channel fade (though it'd probably be cool in SRB2 too)
+		// HWR2 will implement as a rev-subtractive rect because colormaps aren't possible in hardware
+		float fstrength = std::clamp(strength / 31.f, 0.f, 1.f);
+		r = std::clamp((fstrength - (0.f / 3.f)) * 3.f, 0.f, 1.f);
+		g = std::clamp((fstrength - (1.f / 3.f)) * 3.f, 0.f, 1.f);
+		b = std::clamp((fstrength - (2.f / 3.f)) * 3.f, 0.f, 1.f);
+		a = 1;
 
-		// heavily simplified -- we don't need to know x or y
-		// position when we're doing a full screen fade
-		for (; buf < deststop; ++buf)
-			*buf = fadetable[*buf];
+		blendmode = hwr2::Draw2dBlend::kReverseSubtractive;
 	}
+	else
+	{
+		// Historically TRANSMAP fade
+		// This is done by modulative (transparent) blend to the given palette color.
+		byteColor_t bc = V_GetColor(color).s;
+		r = bc.red / 255.f;
+		g = bc.green / 255.f;
+		b = bc.blue / 255.f;
+		a = softwaretranstohwr[std::clamp(static_cast<int>(strength), 0, 10)] / 255.f;
+		blendmode = hwr2::Draw2dBlend::kModulate;
+	}
+
+	g_2d.begin_quad()
+		.blend(blendmode)
+		.color(r, g, b, a)
+		.rect(0, 0, vid.width, vid.height)
+		.done();
 }
 
 //
@@ -1643,6 +1568,8 @@ void V_DrawFadeScreen(UINT16 color, UINT8 strength)
 //
 void V_DrawCustomFadeScreen(const char *lump, UINT8 strength)
 {
+	(void)lump;
+	(void)strength;
 #ifdef HWRENDER
 	if (rendermode != render_soft && rendermode != render_none)
 	{
@@ -1651,57 +1578,30 @@ void V_DrawCustomFadeScreen(const char *lump, UINT8 strength)
 	}
 #endif
 
-	{
-		lumpnum_t lumpnum = LUMPERROR;
-		lighttable_t *clm = NULL;
-
-		if (lump != NULL)
-			lumpnum = W_GetNumForName(lump);
-		else
-			return;
-
-		if (lumpnum != LUMPERROR)
-		{
-			clm = static_cast<lighttable_t*>(Z_MallocAlign(COLORMAP_SIZE, PU_STATIC, NULL, 8));
-			W_ReadLump(lumpnum, clm);
-
-			if (clm != NULL)
-			{
-				const UINT8 *fadetable = ((UINT8 *)clm + strength*256);
-				const UINT8 *deststop = screens[0] + vid.rowbytes * vid.height;
-				UINT8 *buf = screens[0];
-
-				// heavily simplified -- we don't need to know x or y
-				// position when we're doing a full screen fade
-				for (; buf < deststop; ++buf)
-					*buf = fadetable[*buf];
-
-				Z_Free(clm);
-				clm = NULL;
-			}
-		}
-	}
+	// NOTE: This is not implementable in HWR2.
 }
 
 // Simple translucency with one color, over a set number of lines starting from the top.
 void V_DrawFadeConsBack(INT32 plines)
 {
-	UINT8 *deststop, *buf;
-
+	UINT32 hwcolor = V_GetHWConsBackColor();
 #ifdef HWRENDER // not win32 only 19990829 by Kin
 	if (rendermode == render_opengl)
 	{
-		UINT32 hwcolor = V_GetHWConsBackColor();
 		HWR_DrawConsoleBack(hwcolor, plines);
 		return;
 	}
 #endif
 
-	// heavily simplified -- we don't need to know x or y position,
-	// just the stop position
-	deststop = screens[0] + vid.rowbytes * std::min(plines, vid.height);
-	for (buf = screens[0]; buf < deststop; ++buf)
-		*buf = consolebgmap[*buf];
+	float r = ((hwcolor & 0xFF000000) >> 24) / 255.f;
+	float g = ((hwcolor & 0xFF0000) >> 16) / 255.f;
+	float b = ((hwcolor & 0xFF00) >> 8) / 255.f;
+	float a = 0.5f;
+	g_2d.begin_quad()
+		.rect(0, 0, vid.width, plines)
+		.blend(hwr2::Draw2dBlend::kModulate)
+		.color(r, g, b, a)
+		.done();
 }
 
 
@@ -1718,26 +1618,16 @@ void V_EncoreInvertScreen(void)
 	}
 #endif
 
-	{
-		const UINT8 *deststop = screens[0] + vid.rowbytes * vid.height;
-		UINT8 *buf = screens[0];
-
-		for (; buf < deststop; ++buf)
-		{
-			*buf = NearestColor(
-				255 - pLocalPalette[*buf].s.red,
-				255 - pLocalPalette[*buf].s.green,
-				255 - pLocalPalette[*buf].s.blue
-			);
-		}
-	}
+	g_2d.begin_quad()
+		.blend(hwr2::Draw2dBlend::kInvertDest)
+		.color(1, 1, 1, 1)
+		.rect(0, 0, vid.width, vid.height)
+		.done();
 }
 
 // Very similar to F_DrawFadeConsBack, except we draw from the middle(-ish) of the screen to the bottom.
 void V_DrawPromptBack(INT32 boxheight, INT32 color)
 {
-	UINT8 *deststop, *buf;
-
 	if (color >= 256 && color < 512)
 	{
 		if (boxheight < 0)
@@ -1753,50 +1643,50 @@ void V_DrawPromptBack(INT32 boxheight, INT32 color)
 	if (color == INT32_MAX)
 		color = cons_backcolor.value;
 
+	UINT32 hwcolor;
+	switch (color)
+	{
+		case 0:		hwcolor = 0xffffff00;	break; 	// White
+		case 1:		hwcolor = 0x00000000;	break; 	// Black // Note this is different from V_DrawFadeConsBack
+		case 2:		hwcolor = 0xdeb88700;	break;	// Sepia
+		case 3:		hwcolor = 0x40201000;	break; 	// Brown
+		case 4:		hwcolor = 0xfa807200;	break; 	// Pink
+		case 5:		hwcolor = 0xff69b400;	break; 	// Raspberry
+		case 6:		hwcolor = 0xff000000;	break; 	// Red
+		case 7:		hwcolor = 0xffd68300;	break;	// Creamsicle
+		case 8:		hwcolor = 0xff800000;	break; 	// Orange
+		case 9:		hwcolor = 0xdaa52000;	break; 	// Gold
+		case 10:	hwcolor = 0x80800000;	break; 	// Yellow
+		case 11:	hwcolor = 0x00ff0000;	break; 	// Emerald
+		case 12:	hwcolor = 0x00800000;	break; 	// Green
+		case 13:	hwcolor = 0x4080ff00;	break; 	// Cyan
+		case 14:	hwcolor = 0x4682b400;	break; 	// Steel
+		case 15:	hwcolor = 0x1e90ff00;	break;	// Periwinkle
+		case 16:	hwcolor = 0x0000ff00;	break; 	// Blue
+		case 17:	hwcolor = 0xff00ff00;	break; 	// Purple
+		case 18:	hwcolor = 0xee82ee00;	break; 	// Lavender
+		// Default green
+		default:	hwcolor = 0x00800000;	break;
+	}
+
 #ifdef HWRENDER
 	if (rendermode == render_opengl)
 	{
-		UINT32 hwcolor;
-		switch (color)
-		{
-			case 0:		hwcolor = 0xffffff00;	break; 	// White
-			case 1:		hwcolor = 0x00000000;	break; 	// Black // Note this is different from V_DrawFadeConsBack
-			case 2:		hwcolor = 0xdeb88700;	break;	// Sepia
-			case 3:		hwcolor = 0x40201000;	break; 	// Brown
-			case 4:		hwcolor = 0xfa807200;	break; 	// Pink
-			case 5:		hwcolor = 0xff69b400;	break; 	// Raspberry
-			case 6:		hwcolor = 0xff000000;	break; 	// Red
-			case 7:		hwcolor = 0xffd68300;	break;	// Creamsicle
-			case 8:		hwcolor = 0xff800000;	break; 	// Orange
-			case 9:		hwcolor = 0xdaa52000;	break; 	// Gold
-			case 10:	hwcolor = 0x80800000;	break; 	// Yellow
-			case 11:	hwcolor = 0x00ff0000;	break; 	// Emerald
-			case 12:	hwcolor = 0x00800000;	break; 	// Green
-			case 13:	hwcolor = 0x4080ff00;	break; 	// Cyan
-			case 14:	hwcolor = 0x4682b400;	break; 	// Steel
-			case 15:	hwcolor = 0x1e90ff00;	break;	// Periwinkle
-			case 16:	hwcolor = 0x0000ff00;	break; 	// Blue
-			case 17:	hwcolor = 0xff00ff00;	break; 	// Purple
-			case 18:	hwcolor = 0xee82ee00;	break; 	// Lavender
-			// Default green
-			default:	hwcolor = 0x00800000;	break;
-		}
 		HWR_DrawTutorialBack(hwcolor, boxheight);
 		return;
 	}
 #endif
 
-	CON_SetupBackColormapEx(color, true);
-
-	// heavily simplified -- we don't need to know x or y position,
-	// just the start and stop positions
-	buf = deststop = screens[0] + vid.rowbytes * vid.height;
-	if (boxheight < 0)
-		buf += vid.rowbytes * boxheight;
-	else // 4 lines of space plus gaps between and some leeway
-		buf -= vid.rowbytes * ((boxheight * 4) + (boxheight/2)*5);
-	for (; buf < deststop; ++buf)
-		*buf = promptbgmap[*buf];
+	float r = ((color & 0xFF000000) >> 24) / 255.f;
+	float g = ((color & 0xFF0000) >> 16) / 255.f;
+	float b = ((color & 0xFF00) >> 8) / 255.f;
+	float a = (color == 0 ? 0xC0 : 0x80) / 255.f; // make black darker, like software
+
+	INT32 real_boxheight = (boxheight * 4) + (boxheight / 2) * 5;
+	g_2d.begin_quad()
+		.rect(0, vid.height - real_boxheight, vid.width, real_boxheight)
+		.color(r, g, b, a)
+		.done();
 }
 
 // Gets string colormap, used for 0x80 color codes
diff --git a/src/v_video.h b/src/v_video.h
index 497f0e7125f208b49a9bd320b6effa14e07f3191..bda5920d9c375c08e90e04e33938bb6ee44113db 100644
--- a/src/v_video.h
+++ b/src/v_video.h
@@ -22,6 +22,14 @@
 #include "hu_stuff.h" // fonts
 
 #ifdef __cplusplus
+
+#include "hwr2/twodee.hpp"
+
+namespace srb2
+{
+extern hwr2::Twodee g_2d;
+} // namespace srb2
+
 extern "C" {
 #endif
 
diff --git a/thirdparty/CMakeLists.txt b/thirdparty/CMakeLists.txt
index 46e2b2a3d369ace2b9f76871eb623f54fe55f635..6fa5e9687980705457ffa1d815deb6d1c4b45831 100644
--- a/thirdparty/CMakeLists.txt
+++ b/thirdparty/CMakeLists.txt
@@ -31,4 +31,5 @@ include("cpm-libyuv.cmake")
 
 add_subdirectory(tcbrindle_span)
 add_subdirectory(stb_vorbis)
+add_subdirectory(stb_rect_pack)
 add_subdirectory(glad)
diff --git a/thirdparty/cpm-imgui.cmake b/thirdparty/cpm-imgui.cmake
index 2afd71a2c29c5eb9381947f6976a0a0f93b92e11..788643f431d715e383c2da1053318e9042a62544 100644
--- a/thirdparty/cpm-imgui.cmake
+++ b/thirdparty/cpm-imgui.cmake
@@ -31,5 +31,6 @@ if(imgui_ADDED)
 	target_include_directories(imgui PUBLIC "${imgui_BINARY_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/imgui_config")
 	target_compile_definitions(imgui PUBLIC IMGUI_USER_CONFIG="srb2_imconfig.h")
 	target_compile_features(imgui PUBLIC cxx_std_11)
+	target_link_libraries(imgui PRIVATE stb_rect_pack)
 	add_library(imgui::imgui ALIAS imgui)
 endif()
diff --git a/thirdparty/imgui_config/srb2_imconfig.h b/thirdparty/imgui_config/srb2_imconfig.h
index 5c09001b20867ab0ea0d7b93055c07623d46ba7f..48645d16ca98eeba2004ee6fd7597d44718d814b 100644
--- a/thirdparty/imgui_config/srb2_imconfig.h
+++ b/thirdparty/imgui_config/srb2_imconfig.h
@@ -5,6 +5,7 @@
 
 #define IMGUI_DISABLE_OBSOLETE_FUNCTIONS
 #define IMGUI_DISABLE_OBSOLETE_KEYIO
+#define IMGUI_DISABLE_STB_RECT_PACK_IMPLEMENTATION
 
 // We provide needed functionalities provided by default win32 impls through the interface layer
 #define IMGUI_DISABLE_WIN32_FUNCTIONS
diff --git a/thirdparty/stb_rect_pack/CMakeLists.txt b/thirdparty/stb_rect_pack/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..b0e610c95fb6b8454efb1ec52952cba321ab301f
--- /dev/null
+++ b/thirdparty/stb_rect_pack/CMakeLists.txt
@@ -0,0 +1,3 @@
+# Update from https://github.com/nothings/stb
+add_library(stb_rect_pack STATIC stb_rect_pack.c include/stb_rect_pack.h)
+target_include_directories(stb_rect_pack PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
diff --git a/thirdparty/stb_rect_pack/include/stb_rect_pack.h b/thirdparty/stb_rect_pack/include/stb_rect_pack.h
new file mode 100644
index 0000000000000000000000000000000000000000..6a633ce666a8600d7d974996fea072faabe939b3
--- /dev/null
+++ b/thirdparty/stb_rect_pack/include/stb_rect_pack.h
@@ -0,0 +1,623 @@
+// stb_rect_pack.h - v1.01 - public domain - rectangle packing
+// Sean Barrett 2014
+//
+// Useful for e.g. packing rectangular textures into an atlas.
+// Does not do rotation.
+//
+// Before #including,
+//
+//    #define STB_RECT_PACK_IMPLEMENTATION
+//
+// in the file that you want to have the implementation.
+//
+// Not necessarily the awesomest packing method, but better than
+// the totally naive one in stb_truetype (which is primarily what
+// this is meant to replace).
+//
+// Has only had a few tests run, may have issues.
+//
+// More docs to come.
+//
+// No memory allocations; uses qsort() and assert() from stdlib.
+// Can override those by defining STBRP_SORT and STBRP_ASSERT.
+//
+// This library currently uses the Skyline Bottom-Left algorithm.
+//
+// Please note: better rectangle packers are welcome! Please
+// implement them to the same API, but with a different init
+// function.
+//
+// Credits
+//
+//  Library
+//    Sean Barrett
+//  Minor features
+//    Martins Mozeiko
+//    github:IntellectualKitty
+//
+//  Bugfixes / warning fixes
+//    Jeremy Jaussaud
+//    Fabian Giesen
+//
+// Version history:
+//
+//     1.01  (2021-07-11)  always use large rect mode, expose STBRP__MAXVAL in public section
+//     1.00  (2019-02-25)  avoid small space waste; gracefully fail too-wide rectangles
+//     0.99  (2019-02-07)  warning fixes
+//     0.11  (2017-03-03)  return packing success/fail result
+//     0.10  (2016-10-25)  remove cast-away-const to avoid warnings
+//     0.09  (2016-08-27)  fix compiler warnings
+//     0.08  (2015-09-13)  really fix bug with empty rects (w=0 or h=0)
+//     0.07  (2015-09-13)  fix bug with empty rects (w=0 or h=0)
+//     0.06  (2015-04-15)  added STBRP_SORT to allow replacing qsort
+//     0.05:  added STBRP_ASSERT to allow replacing assert
+//     0.04:  fixed minor bug in STBRP_LARGE_RECTS support
+//     0.01:  initial release
+//
+// LICENSE
+//
+//   See end of file for license information.
+
+//////////////////////////////////////////////////////////////////////////////
+//
+//       INCLUDE SECTION
+//
+
+#ifndef STB_INCLUDE_STB_RECT_PACK_H
+#define STB_INCLUDE_STB_RECT_PACK_H
+
+#define STB_RECT_PACK_VERSION  1
+
+#ifdef STBRP_STATIC
+#define STBRP_DEF static
+#else
+#define STBRP_DEF extern
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct stbrp_context stbrp_context;
+typedef struct stbrp_node    stbrp_node;
+typedef struct stbrp_rect    stbrp_rect;
+
+typedef int            stbrp_coord;
+
+#define STBRP__MAXVAL  0x7fffffff
+// Mostly for internal use, but this is the maximum supported coordinate value.
+
+STBRP_DEF int stbrp_pack_rects (stbrp_context *context, stbrp_rect *rects, int num_rects);
+// Assign packed locations to rectangles. The rectangles are of type
+// 'stbrp_rect' defined below, stored in the array 'rects', and there
+// are 'num_rects' many of them.
+//
+// Rectangles which are successfully packed have the 'was_packed' flag
+// set to a non-zero value and 'x' and 'y' store the minimum location
+// on each axis (i.e. bottom-left in cartesian coordinates, top-left
+// if you imagine y increasing downwards). Rectangles which do not fit
+// have the 'was_packed' flag set to 0.
+//
+// You should not try to access the 'rects' array from another thread
+// while this function is running, as the function temporarily reorders
+// the array while it executes.
+//
+// To pack into another rectangle, you need to call stbrp_init_target
+// again. To continue packing into the same rectangle, you can call
+// this function again. Calling this multiple times with multiple rect
+// arrays will probably produce worse packing results than calling it
+// a single time with the full rectangle array, but the option is
+// available.
+//
+// The function returns 1 if all of the rectangles were successfully
+// packed and 0 otherwise.
+
+struct stbrp_rect
+{
+   // reserved for your use:
+   int            id;
+
+   // input:
+   stbrp_coord    w, h;
+
+   // output:
+   stbrp_coord    x, y;
+   int            was_packed;  // non-zero if valid packing
+
+}; // 16 bytes, nominally
+
+
+STBRP_DEF void stbrp_init_target (stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes);
+// Initialize a rectangle packer to:
+//    pack a rectangle that is 'width' by 'height' in dimensions
+//    using temporary storage provided by the array 'nodes', which is 'num_nodes' long
+//
+// You must call this function every time you start packing into a new target.
+//
+// There is no "shutdown" function. The 'nodes' memory must stay valid for
+// the following stbrp_pack_rects() call (or calls), but can be freed after
+// the call (or calls) finish.
+//
+// Note: to guarantee best results, either:
+//       1. make sure 'num_nodes' >= 'width'
+//   or  2. call stbrp_allow_out_of_mem() defined below with 'allow_out_of_mem = 1'
+//
+// If you don't do either of the above things, widths will be quantized to multiples
+// of small integers to guarantee the algorithm doesn't run out of temporary storage.
+//
+// If you do #2, then the non-quantized algorithm will be used, but the algorithm
+// may run out of temporary storage and be unable to pack some rectangles.
+
+STBRP_DEF void stbrp_setup_allow_out_of_mem (stbrp_context *context, int allow_out_of_mem);
+// Optionally call this function after init but before doing any packing to
+// change the handling of the out-of-temp-memory scenario, described above.
+// If you call init again, this will be reset to the default (false).
+
+
+STBRP_DEF void stbrp_setup_heuristic (stbrp_context *context, int heuristic);
+// Optionally select which packing heuristic the library should use. Different
+// heuristics will produce better/worse results for different data sets.
+// If you call init again, this will be reset to the default.
+
+enum
+{
+   STBRP_HEURISTIC_Skyline_default=0,
+   STBRP_HEURISTIC_Skyline_BL_sortHeight = STBRP_HEURISTIC_Skyline_default,
+   STBRP_HEURISTIC_Skyline_BF_sortHeight
+};
+
+
+//////////////////////////////////////////////////////////////////////////////
+//
+// the details of the following structures don't matter to you, but they must
+// be visible so you can handle the memory allocations for them
+
+struct stbrp_node
+{
+   stbrp_coord  x,y;
+   stbrp_node  *next;
+};
+
+struct stbrp_context
+{
+   int width;
+   int height;
+   int align;
+   int init_mode;
+   int heuristic;
+   int num_nodes;
+   stbrp_node *active_head;
+   stbrp_node *free_head;
+   stbrp_node extra[2]; // we allocate two extra nodes so optimal user-node-count is 'width' not 'width+2'
+};
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
+
+//////////////////////////////////////////////////////////////////////////////
+//
+//     IMPLEMENTATION SECTION
+//
+
+#ifdef STB_RECT_PACK_IMPLEMENTATION
+#ifndef STBRP_SORT
+#include <stdlib.h>
+#define STBRP_SORT qsort
+#endif
+
+#ifndef STBRP_ASSERT
+#include <assert.h>
+#define STBRP_ASSERT assert
+#endif
+
+#ifdef _MSC_VER
+#define STBRP__NOTUSED(v)  (void)(v)
+#define STBRP__CDECL       __cdecl
+#else
+#define STBRP__NOTUSED(v)  (void)sizeof(v)
+#define STBRP__CDECL
+#endif
+
+enum
+{
+   STBRP__INIT_skyline = 1
+};
+
+STBRP_DEF void stbrp_setup_heuristic(stbrp_context *context, int heuristic)
+{
+   switch (context->init_mode) {
+      case STBRP__INIT_skyline:
+         STBRP_ASSERT(heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight || heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight);
+         context->heuristic = heuristic;
+         break;
+      default:
+         STBRP_ASSERT(0);
+   }
+}
+
+STBRP_DEF void stbrp_setup_allow_out_of_mem(stbrp_context *context, int allow_out_of_mem)
+{
+   if (allow_out_of_mem)
+      // if it's ok to run out of memory, then don't bother aligning them;
+      // this gives better packing, but may fail due to OOM (even though
+      // the rectangles easily fit). @TODO a smarter approach would be to only
+      // quantize once we've hit OOM, then we could get rid of this parameter.
+      context->align = 1;
+   else {
+      // if it's not ok to run out of memory, then quantize the widths
+      // so that num_nodes is always enough nodes.
+      //
+      // I.e. num_nodes * align >= width
+      //                  align >= width / num_nodes
+      //                  align = ceil(width/num_nodes)
+
+      context->align = (context->width + context->num_nodes-1) / context->num_nodes;
+   }
+}
+
+STBRP_DEF void stbrp_init_target(stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes)
+{
+   int i;
+
+   for (i=0; i < num_nodes-1; ++i)
+      nodes[i].next = &nodes[i+1];
+   nodes[i].next = NULL;
+   context->init_mode = STBRP__INIT_skyline;
+   context->heuristic = STBRP_HEURISTIC_Skyline_default;
+   context->free_head = &nodes[0];
+   context->active_head = &context->extra[0];
+   context->width = width;
+   context->height = height;
+   context->num_nodes = num_nodes;
+   stbrp_setup_allow_out_of_mem(context, 0);
+
+   // node 0 is the full width, node 1 is the sentinel (lets us not store width explicitly)
+   context->extra[0].x = 0;
+   context->extra[0].y = 0;
+   context->extra[0].next = &context->extra[1];
+   context->extra[1].x = (stbrp_coord) width;
+   context->extra[1].y = (1<<30);
+   context->extra[1].next = NULL;
+}
+
+// find minimum y position if it starts at x1
+static int stbrp__skyline_find_min_y(stbrp_context *c, stbrp_node *first, int x0, int width, int *pwaste)
+{
+   stbrp_node *node = first;
+   int x1 = x0 + width;
+   int min_y, visited_width, waste_area;
+
+   STBRP__NOTUSED(c);
+
+   STBRP_ASSERT(first->x <= x0);
+
+   #if 0
+   // skip in case we're past the node
+   while (node->next->x <= x0)
+      ++node;
+   #else
+   STBRP_ASSERT(node->next->x > x0); // we ended up handling this in the caller for efficiency
+   #endif
+
+   STBRP_ASSERT(node->x <= x0);
+
+   min_y = 0;
+   waste_area = 0;
+   visited_width = 0;
+   while (node->x < x1) {
+      if (node->y > min_y) {
+         // raise min_y higher.
+         // we've accounted for all waste up to min_y,
+         // but we'll now add more waste for everything we've visted
+         waste_area += visited_width * (node->y - min_y);
+         min_y = node->y;
+         // the first time through, visited_width might be reduced
+         if (node->x < x0)
+            visited_width += node->next->x - x0;
+         else
+            visited_width += node->next->x - node->x;
+      } else {
+         // add waste area
+         int under_width = node->next->x - node->x;
+         if (under_width + visited_width > width)
+            under_width = width - visited_width;
+         waste_area += under_width * (min_y - node->y);
+         visited_width += under_width;
+      }
+      node = node->next;
+   }
+
+   *pwaste = waste_area;
+   return min_y;
+}
+
+typedef struct
+{
+   int x,y;
+   stbrp_node **prev_link;
+} stbrp__findresult;
+
+static stbrp__findresult stbrp__skyline_find_best_pos(stbrp_context *c, int width, int height)
+{
+   int best_waste = (1<<30), best_x, best_y = (1 << 30);
+   stbrp__findresult fr;
+   stbrp_node **prev, *node, *tail, **best = NULL;
+
+   // align to multiple of c->align
+   width = (width + c->align - 1);
+   width -= width % c->align;
+   STBRP_ASSERT(width % c->align == 0);
+
+   // if it can't possibly fit, bail immediately
+   if (width > c->width || height > c->height) {
+      fr.prev_link = NULL;
+      fr.x = fr.y = 0;
+      return fr;
+   }
+
+   node = c->active_head;
+   prev = &c->active_head;
+   while (node->x + width <= c->width) {
+      int y,waste;
+      y = stbrp__skyline_find_min_y(c, node, node->x, width, &waste);
+      if (c->heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight) { // actually just want to test BL
+         // bottom left
+         if (y < best_y) {
+            best_y = y;
+            best = prev;
+         }
+      } else {
+         // best-fit
+         if (y + height <= c->height) {
+            // can only use it if it first vertically
+            if (y < best_y || (y == best_y && waste < best_waste)) {
+               best_y = y;
+               best_waste = waste;
+               best = prev;
+            }
+         }
+      }
+      prev = &node->next;
+      node = node->next;
+   }
+
+   best_x = (best == NULL) ? 0 : (*best)->x;
+
+   // if doing best-fit (BF), we also have to try aligning right edge to each node position
+   //
+   // e.g, if fitting
+   //
+   //     ____________________
+   //    |____________________|
+   //
+   //            into
+   //
+   //   |                         |
+   //   |             ____________|
+   //   |____________|
+   //
+   // then right-aligned reduces waste, but bottom-left BL is always chooses left-aligned
+   //
+   // This makes BF take about 2x the time
+
+   if (c->heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight) {
+      tail = c->active_head;
+      node = c->active_head;
+      prev = &c->active_head;
+      // find first node that's admissible
+      while (tail->x < width)
+         tail = tail->next;
+      while (tail) {
+         int xpos = tail->x - width;
+         int y,waste;
+         STBRP_ASSERT(xpos >= 0);
+         // find the left position that matches this
+         while (node->next->x <= xpos) {
+            prev = &node->next;
+            node = node->next;
+         }
+         STBRP_ASSERT(node->next->x > xpos && node->x <= xpos);
+         y = stbrp__skyline_find_min_y(c, node, xpos, width, &waste);
+         if (y + height <= c->height) {
+            if (y <= best_y) {
+               if (y < best_y || waste < best_waste || (waste==best_waste && xpos < best_x)) {
+                  best_x = xpos;
+                  STBRP_ASSERT(y <= best_y);
+                  best_y = y;
+                  best_waste = waste;
+                  best = prev;
+               }
+            }
+         }
+         tail = tail->next;
+      }
+   }
+
+   fr.prev_link = best;
+   fr.x = best_x;
+   fr.y = best_y;
+   return fr;
+}
+
+static stbrp__findresult stbrp__skyline_pack_rectangle(stbrp_context *context, int width, int height)
+{
+   // find best position according to heuristic
+   stbrp__findresult res = stbrp__skyline_find_best_pos(context, width, height);
+   stbrp_node *node, *cur;
+
+   // bail if:
+   //    1. it failed
+   //    2. the best node doesn't fit (we don't always check this)
+   //    3. we're out of memory
+   if (res.prev_link == NULL || res.y + height > context->height || context->free_head == NULL) {
+      res.prev_link = NULL;
+      return res;
+   }
+
+   // on success, create new node
+   node = context->free_head;
+   node->x = (stbrp_coord) res.x;
+   node->y = (stbrp_coord) (res.y + height);
+
+   context->free_head = node->next;
+
+   // insert the new node into the right starting point, and
+   // let 'cur' point to the remaining nodes needing to be
+   // stiched back in
+
+   cur = *res.prev_link;
+   if (cur->x < res.x) {
+      // preserve the existing one, so start testing with the next one
+      stbrp_node *next = cur->next;
+      cur->next = node;
+      cur = next;
+   } else {
+      *res.prev_link = node;
+   }
+
+   // from here, traverse cur and free the nodes, until we get to one
+   // that shouldn't be freed
+   while (cur->next && cur->next->x <= res.x + width) {
+      stbrp_node *next = cur->next;
+      // move the current node to the free list
+      cur->next = context->free_head;
+      context->free_head = cur;
+      cur = next;
+   }
+
+   // stitch the list back in
+   node->next = cur;
+
+   if (cur->x < res.x + width)
+      cur->x = (stbrp_coord) (res.x + width);
+
+#ifdef _DEBUG
+   cur = context->active_head;
+   while (cur->x < context->width) {
+      STBRP_ASSERT(cur->x < cur->next->x);
+      cur = cur->next;
+   }
+   STBRP_ASSERT(cur->next == NULL);
+
+   {
+      int count=0;
+      cur = context->active_head;
+      while (cur) {
+         cur = cur->next;
+         ++count;
+      }
+      cur = context->free_head;
+      while (cur) {
+         cur = cur->next;
+         ++count;
+      }
+      STBRP_ASSERT(count == context->num_nodes+2);
+   }
+#endif
+
+   return res;
+}
+
+static int STBRP__CDECL rect_height_compare(const void *a, const void *b)
+{
+   const stbrp_rect *p = (const stbrp_rect *) a;
+   const stbrp_rect *q = (const stbrp_rect *) b;
+   if (p->h > q->h)
+      return -1;
+   if (p->h < q->h)
+      return  1;
+   return (p->w > q->w) ? -1 : (p->w < q->w);
+}
+
+static int STBRP__CDECL rect_original_order(const void *a, const void *b)
+{
+   const stbrp_rect *p = (const stbrp_rect *) a;
+   const stbrp_rect *q = (const stbrp_rect *) b;
+   return (p->was_packed < q->was_packed) ? -1 : (p->was_packed > q->was_packed);
+}
+
+STBRP_DEF int stbrp_pack_rects(stbrp_context *context, stbrp_rect *rects, int num_rects)
+{
+   int i, all_rects_packed = 1;
+
+   // we use the 'was_packed' field internally to allow sorting/unsorting
+   for (i=0; i < num_rects; ++i) {
+      rects[i].was_packed = i;
+   }
+
+   // sort according to heuristic
+   STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_height_compare);
+
+   for (i=0; i < num_rects; ++i) {
+      if (rects[i].w == 0 || rects[i].h == 0) {
+         rects[i].x = rects[i].y = 0;  // empty rect needs no space
+      } else {
+         stbrp__findresult fr = stbrp__skyline_pack_rectangle(context, rects[i].w, rects[i].h);
+         if (fr.prev_link) {
+            rects[i].x = (stbrp_coord) fr.x;
+            rects[i].y = (stbrp_coord) fr.y;
+         } else {
+            rects[i].x = rects[i].y = STBRP__MAXVAL;
+         }
+      }
+   }
+
+   // unsort
+   STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_original_order);
+
+   // set was_packed flags and all_rects_packed status
+   for (i=0; i < num_rects; ++i) {
+      rects[i].was_packed = !(rects[i].x == STBRP__MAXVAL && rects[i].y == STBRP__MAXVAL);
+      if (!rects[i].was_packed)
+         all_rects_packed = 0;
+   }
+
+   // return the all_rects_packed status
+   return all_rects_packed;
+}
+#endif
+
+/*
+------------------------------------------------------------------------------
+This software is available under 2 licenses -- choose whichever you prefer.
+------------------------------------------------------------------------------
+ALTERNATIVE A - MIT License
+Copyright (c) 2017 Sean Barrett
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+------------------------------------------------------------------------------
+ALTERNATIVE B - Public Domain (www.unlicense.org)
+This is free and unencumbered software released into the public domain.
+Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+software, either in source code form or as a compiled binary, for any purpose,
+commercial or non-commercial, and by any means.
+In jurisdictions that recognize copyright laws, the author or authors of this
+software dedicate any and all copyright interest in the software to the public
+domain. We make this dedication for the benefit of the public at large and to
+the detriment of our heirs and successors. We intend this dedication to be an
+overt act of relinquishment in perpetuity of all present and future rights to
+this software under copyright law.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+------------------------------------------------------------------------------
+*/
diff --git a/thirdparty/stb_rect_pack/stb_rect_pack.c b/thirdparty/stb_rect_pack/stb_rect_pack.c
new file mode 100644
index 0000000000000000000000000000000000000000..3f3391d6f9b1fb8e31ff1151d9c07de9a8485da9
--- /dev/null
+++ b/thirdparty/stb_rect_pack/stb_rect_pack.c
@@ -0,0 +1,2 @@
+#define STB_RECT_PACK_IMPLEMENTATION
+#include "include/stb_rect_pack.h"