Skip to content

Creating a Perspective UI with QuadWarp

Difficulty Intermediate Time 60 minutes~

1. Draw UI to a render texture

  • To use QuadWarp, first draw the UI to a render texture.
Code
# include <Siv3D.hpp>

void Main()
{
	// Change window size to 1000x600
	Window::Resize(1000, 600);

	// Set background color
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	// Font for UI
	const Font font{ FontMethod::MSDF, 48, Typeface::Bold };

	// Theme color
	const ColorF PrimaryColor{ 0.98, 0.96, 0.94 };

	// Rectangle for each UI element
	const Rect BaseRect{ 0, 0, 600, 600 };
	const Rect Button1{ 40, 40, 560, 200 };
	const Rect Button2{ 100, 260, 240, 100 };
	const Rect Button3{ 360, 260, 240, 100 };
	const Rect Button4{ 160, 380, 440, 120 };

	// Render texture for drawing UI
	const MSRenderTexture renderTexture{ BaseRect.size };

	while (System::Update())
	{
		// Draw UI to render texture
		{
			// Clear renderTexture with ColorF{ 0.5 } and
			// set renderTexture as render target
			const ScopedRenderTarget2D renderTarget{ renderTexture.clear(ColorF{ 0.5 }) };

			// Draw UI
			{
				// Explore button
				Button1.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button1.draw(PrimaryColor);
				font(U"Explore").draw(88, Arg::leftCenter(80, 140), ColorF{ 0.4, 0.3, 0.2 });

				// Mission button
				Button2.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button2.draw(PrimaryColor);
				font(U"Mission").draw(44, Arg::leftCenter(120, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Formation button
				Button3.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button3.draw(PrimaryColor);
				font(U"Formation").draw(44, Arg::leftCenter(380, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Event area
				Button4.draw(ColorF{ 0.2, 0.4, 0.6 });
				font(U"Event").draw(33, Arg::leftCenter(180, 415));

				// Footer
				Rect{ 60, 540, 540, 60 }.draw(ColorF{ 0.0, 0.6 });
			}

			// Complete the render texture contents by issuing 2D draw commands (Flush)
			// and resolving MSAA (Resolve)
			Graphics2D::Flush();
			renderTexture.resolve();
		}

		// Draw render texture to screen
		renderTexture.scaled(0.75).draw(Vec2{ 300, 40 });
	}
}

2. Draw to a transparent render texture

  • When you want to make areas other than the UI transparent, draw the UI to a render texture cleared with a transparent color like ColorF{ 1.0, 0.0 }.
  • However, since all alpha values in the render texture are 0 as is, apply a blend state that writes the maximum alpha value.
Code
# include <Siv3D.hpp>

/// @brief Returns a blend state that writes the maximum alpha value.
BlendState MaxAlphaBlend()
{
	BlendState blend = BlendState::Default2D;
	blend.opAlpha = BlendOp::Max;
	blend.dstAlpha = Blend::DestAlpha;
	blend.srcAlpha = Blend::SrcAlpha;
	return blend;
}

void Main()
{
	// Change window size to 1000x600
	Window::Resize(1000, 600);

	// Set background color
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	// Font for UI
	const Font font{ FontMethod::MSDF, 48, Typeface::Bold };

	// Theme color
	const ColorF PrimaryColor{ 0.98, 0.96, 0.94 };

	// Rectangle for each UI element
	const Rect BaseRect{ 0, 0, 600, 600 };
	const Rect Button1{ 40, 40, 560, 200 };
	const Rect Button2{ 100, 260, 240, 100 };
	const Rect Button3{ 360, 260, 240, 100 };
	const Rect Button4{ 160, 380, 440, 120 };

	// Render texture for drawing UI
	const MSRenderTexture renderTexture{ BaseRect.size };

	while (System::Update())
	{
		// Draw UI to render texture
		{
			// Clear renderTexture with ColorF{ 1.0, 0.0 } and
			// set renderTexture as render target
			const ScopedRenderTarget2D renderTarget{ renderTexture.clear(ColorF{ 1.0, 0.0 }) };

			// Apply blend state that writes maximum alpha value
			const ScopedRenderStates2D renderState{ MaxAlphaBlend() };

			// Draw UI
			{
				// Explore button
				Button1.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button1.draw(PrimaryColor);
				font(U"Explore").draw(88, Arg::leftCenter(80, 140), ColorF{ 0.4, 0.3, 0.2 });

				// Mission button
				Button2.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button2.draw(PrimaryColor);
				font(U"Mission").draw(44, Arg::leftCenter(120, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Formation button
				Button3.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button3.draw(PrimaryColor);
				font(U"Formation").draw(44, Arg::leftCenter(380, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Event area
				Button4.draw(ColorF{ 0.2, 0.4, 0.6 });
				font(U"Event").draw(33, Arg::leftCenter(180, 415));

				// Footer
				Rect{ 60, 540, 540, 60 }.draw(ColorF{ 0.0, 0.6 });
			}

			// Complete the render texture contents by issuing 2D draw commands (Flush)
			// and resolving MSAA (Resolve)
			Graphics2D::Flush();
			renderTexture.resolve();
		}

		// Draw render texture to screen
		renderTexture.scaled(0.75).draw(Vec2{ 300, 40 });
	}
}

3. Prepare the target Quad for projection

  • Prepare a Quad that specifies how to project and draw the render texture.
Code
# include <Siv3D.hpp>

/// @brief Returns a blend state that writes the maximum alpha value.
BlendState MaxAlphaBlend()
{
	BlendState blend = BlendState::Default2D;
	blend.opAlpha = BlendOp::Max;
	blend.dstAlpha = Blend::DestAlpha;
	blend.srcAlpha = Blend::SrcAlpha;
	return blend;
}

void Main()
{
	// Change window size to 1000x600
	Window::Resize(1000, 600);

	// Set background color
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	// Font for UI
	const Font font{ FontMethod::MSDF, 48, Typeface::Bold };

	// Theme color
	const ColorF PrimaryColor{ 0.98, 0.96, 0.94 };

	// Rectangle for each UI element
	const Rect BaseRect{ 0, 0, 600, 600 };
	const Rect Button1{ 40, 40, 560, 200 };
	const Rect Button2{ 100, 260, 240, 100 };
	const Rect Button3{ 360, 260, 240, 100 };
	const Rect Button4{ 160, 380, 440, 120 };

	// Render texture for drawing UI
	const MSRenderTexture renderTexture{ BaseRect.size };

	// Target quadrilateral for projection
	const Quad TargetQuad{ 500, 60, 1000, 0, 1000, 600, 480, 520 };

	while (System::Update())
	{
		// Draw UI to render texture
		{
			// Clear renderTexture with ColorF{ 1.0, 0.0 } and
			// set renderTexture as render target
			const ScopedRenderTarget2D renderTarget{ renderTexture.clear(ColorF{ 1.0, 0.0 }) };

			// Apply blend state that writes maximum alpha value
			const ScopedRenderStates2D renderState{ MaxAlphaBlend() };

			// Draw UI
			{
				// Explore button
				Button1.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button1.draw(PrimaryColor);
				font(U"Explore").draw(88, Arg::leftCenter(80, 140), ColorF{ 0.4, 0.3, 0.2 });

				// Mission button
				Button2.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button2.draw(PrimaryColor);
				font(U"Mission").draw(44, Arg::leftCenter(120, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Formation button
				Button3.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button3.draw(PrimaryColor);
				font(U"Formation").draw(44, Arg::leftCenter(380, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Event area
				Button4.draw(ColorF{ 0.2, 0.4, 0.6 });
				font(U"Event").draw(33, Arg::leftCenter(180, 415));

				// Footer
				Rect{ 60, 540, 540, 60 }.draw(ColorF{ 0.0, 0.6 });
			}

			// Complete the render texture contents by issuing 2D draw commands (Flush)
			// and resolving MSAA (Resolve)
			Graphics2D::Flush();
			renderTexture.resolve();
		}

		// Draw render texture to screen
		renderTexture.scaled(0.75).draw(Vec2{ 300, 40 });

		// Draw the target quadrilateral
		TargetQuad.draw(ColorF{ 1.0, 0.0, 0.0, 0.5 });
	}
}

4. Using QuadWarp

  • Using QuadWarp, you can project and draw textures to areas specified by Quad.
Code
# include <Siv3D.hpp>

/// @brief Returns a blend state that writes the maximum alpha value.
BlendState MaxAlphaBlend()
{
	BlendState blend = BlendState::Default2D;
	blend.opAlpha = BlendOp::Max;
	blend.dstAlpha = Blend::DestAlpha;
	blend.srcAlpha = Blend::SrcAlpha;
	return blend;
}

void Main()
{
	// Change window size to 1000x600
	Window::Resize(1000, 600);

	// Set background color
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	// Font for UI
	const Font font{ FontMethod::MSDF, 48, Typeface::Bold };

	// Theme color
	const ColorF PrimaryColor{ 0.98, 0.96, 0.94 };

	// Rectangle for each UI element
	const Rect BaseRect{ 0, 0, 600, 600 };
	const Rect Button1{ 40, 40, 560, 200 };
	const Rect Button2{ 100, 260, 240, 100 };
	const Rect Button3{ 360, 260, 240, 100 };
	const Rect Button4{ 160, 380, 440, 120 };

	// Render texture for drawing UI
	const MSRenderTexture renderTexture{ BaseRect.size };

	// Target quadrilateral for projection
	const Quad TargetQuad{ 500, 60, 1000, 0, 1000, 600, 480, 520 };

	while (System::Update())
	{
		// Draw UI to render texture
		{
			// Clear renderTexture with ColorF{ 1.0, 0.0 } and
			// set renderTexture as render target
			const ScopedRenderTarget2D renderTarget{ renderTexture.clear(ColorF{ 1.0, 0.0 }) };

			// Apply blend state that writes maximum alpha value
			const ScopedRenderStates2D renderState{ MaxAlphaBlend() };

			// Draw UI
			{
				// Explore button
				Button1.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button1.draw(PrimaryColor);
				font(U"Explore").draw(88, Arg::leftCenter(80, 140), ColorF{ 0.4, 0.3, 0.2 });

				// Mission button
				Button2.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button2.draw(PrimaryColor);
				font(U"Mission").draw(44, Arg::leftCenter(120, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Formation button
				Button3.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
				Button3.draw(PrimaryColor);
				font(U"Formation").draw(44, Arg::leftCenter(380, 310), ColorF{ 0.4, 0.3, 0.2 });

				// Event area
				Button4.draw(ColorF{ 0.2, 0.4, 0.6 });
				font(U"Event").draw(33, Arg::leftCenter(180, 415));

				// Footer
				Rect{ 60, 540, 540, 60 }.draw(ColorF{ 0.0, 0.6 });
			}

			// Complete the render texture contents by issuing 2D draw commands (Flush)
			// and resolving MSAA (Resolve)
			Graphics2D::Flush();
			renderTexture.resolve();
		}

		// Draw depth-style UI
		{
			// Apply sampler state suitable for QuadWarp (prevents roughness in reduced areas)
			const ScopedRenderStates2D sampler{ SamplerState::ClampAniso };

			// Project and draw render texture to TargetQuad
			Shader::QuadWarp(TargetQuad, renderTexture);
		}
	}
}

5. Hit detection for transformed buttons

  • Use Mat3x3's .transformRect() to convert Rect to Quad after coordinate transformation.
Code
# include <Siv3D.hpp>

/// @brief Returns a blend state that writes the maximum alpha value.
BlendState MaxAlphaBlend()
{
	BlendState blend = BlendState::Default2D;
	blend.opAlpha = BlendOp::Max;
	blend.dstAlpha = Blend::DestAlpha;
	blend.srcAlpha = Blend::SrcAlpha;
	return blend;
}

void Main()
{
	// Change window size to 1000x600
	Window::Resize(1000, 600);

	// Set background color
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	// Font for UI
	const Font font{ FontMethod::MSDF, 48, Typeface::Bold };

	// Theme color
	const ColorF PrimaryColor{ 0.98, 0.96, 0.94 };

	// Hover color
	const ColorF HoverColor{ 1.0, 0.96, 0.8 };

	// Rectangle for each UI element
	const Rect BaseRect{ 0, 0, 600, 600 };
	const Rect Button1{ 40, 40, 560, 200 };
	const Rect Button2{ 100, 260, 240, 100 };
	const Rect Button3{ 360, 260, 240, 100 };
	const Rect Button4{ 160, 380, 440, 120 };

	// Render texture for drawing UI
	const MSRenderTexture renderTexture{ BaseRect.size };

	// Target quadrilateral for projection
	const Quad TargetQuad{ 500, 60, 1000, 0, 1000, 600, 480, 520 };

	// Get QuadWarp transformation matrix (BaseRect → TargetQuad)
	const Mat3x3 projection = Mat3x3::Homography(BaseRect, TargetQuad);

	// Projected quadrilaterals for each button
	const Quad Button1Quad = projection.transformRect(Button1);
	const Quad Button2Quad = projection.transformRect(Button2);
	const Quad Button3Quad = projection.transformRect(Button3);
	const Quad Button4Quad = projection.transformRect(Button4);

	while (System::Update())
	{
		// Draw UI to render texture
		{
			// Clear renderTexture with ColorF{ 1.0, 0.0 } and
			// set renderTexture as render target
			const ScopedRenderTarget2D renderTarget{ renderTexture.clear(ColorF{ 1.0, 0.0 }) };

			// Apply blend state that writes maximum alpha value
			const ScopedRenderStates2D renderState{ MaxAlphaBlend() };

			// Draw UI
			{
				// Explore button
				{
					Button1.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
					Button1.draw(Button1Quad.mouseOver() ? HoverColor : PrimaryColor);
					font(U"Explore").draw(88, Arg::leftCenter(80, 140), ColorF{ 0.4, 0.3, 0.2 });
					if (Button1Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Mission button
				{
					Button2.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
					Button2.draw(Button2Quad.mouseOver() ? HoverColor : PrimaryColor);
					font(U"Mission").draw(44, Arg::leftCenter(120, 310), ColorF{ 0.4, 0.3, 0.2 });
					if (Button2Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Formation button
				{
					Button3.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
					Button3.draw(Button3Quad.mouseOver() ? HoverColor : PrimaryColor);
					font(U"Formation").draw(44, Arg::leftCenter(380, 310), ColorF{ 0.4, 0.3, 0.2 });
					if (Button3Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Event area
				{
					Button4.draw(ColorF{ 0.2, 0.4, 0.6 });
					font(U"Event").draw(33, Arg::leftCenter(180, 415));
					if (Button4Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Footer
				{
					Rect{ 60, 540, 540, 60 }.draw(ColorF{ 0.0, 0.6 });
				}
			}

			// Complete the render texture contents by issuing 2D draw commands (Flush)
			// and resolving MSAA (Resolve)
			Graphics2D::Flush();
			renderTexture.resolve();
		}

		// Draw depth-style UI
		{
			// Apply sampler state suitable for QuadWarp (prevents roughness in reduced areas)
			const ScopedRenderStates2D sampler{ SamplerState::ClampAniso };

			// Project and draw render texture to TargetQuad
			Shader::QuadWarp(TargetQuad, renderTexture);
		}
	}
}

6. Completion

  • Make the UI more lively.
Code
# include <Siv3D.hpp>

/// @brief Returns a blend state that writes the maximum alpha value.
BlendState MaxAlphaBlend()
{
	BlendState blend = BlendState::Default2D;
	blend.opAlpha = BlendOp::Max;
	blend.dstAlpha = Blend::DestAlpha;
	blend.srcAlpha = Blend::SrcAlpha;
	return blend;
}

void Main()
{
	// Change window size to 1000x600
	Window::Resize(1000, 600);

	// Set background color
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	// Font for UI
	const Font font{ FontMethod::MSDF, 48, Typeface::Bold };

	// Theme color
	const ColorF PrimaryColor{ 0.98, 0.96, 0.94 };

	// Hover color
	const ColorF HoverColor{ 1.0, 0.96, 0.8 };

	// Rectangle for each UI element
	const Rect BaseRect{ 0, 0, 600, 600 };
	const Rect Button1{ 40, 40, 560, 200 };
	const Rect Button2{ 100, 260, 240, 100 };
	const Rect Button3{ 360, 260, 240, 100 };
	const Rect Button4{ 160, 380, 440, 120 };
	const Rect Button5{ Arg::center(230, 570), 40 };

	// Icons and emojis
	const Texture compassIcon{ 0xF018B_icon, 90 };
	const Texture swordIcon{ 0xF18BE_icon, 90 };
	const Texture plusIcon{ 0xF0417_icon, 42 };
	const Texture moneyEmoji{ U"💰"_emoji };
	const Texture gemEmoji{ U"💎"_emoji };

	// Render texture for drawing UI
	const MSRenderTexture renderTexture{ BaseRect.size };

	// Target quadrilateral for projection
	const Quad TargetQuad{ 500, 60, 1000, 0, 1000, 600, 480, 520 };

	// Get QuadWarp transformation matrix (BaseRect → TargetQuad)
	const Mat3x3 projection = Mat3x3::Homography(BaseRect, TargetQuad);

	// Projected quadrilaterals for each button
	const Quad Button1Quad = projection.transformRect(Button1);
	const Quad Button2Quad = projection.transformRect(Button2);
	const Quad Button3Quad = projection.transformRect(Button3);
	const Quad Button4Quad = projection.transformRect(Button4);
	const Quad Button5Quad = projection.transformRect(Button5);

	while (System::Update())
	{
		// Draw UI to render texture
		{
			// Clear renderTexture with ColorF{ 1.0, 0.0 } and
			// set renderTexture as render target
			const ScopedRenderTarget2D renderTarget{ renderTexture.clear(ColorF{ 1.0, 0.0 }) };

			// Apply blend state that writes maximum alpha value
			const ScopedRenderStates2D renderState{ MaxAlphaBlend() };

			// Draw UI
			{
				// Explore button
				{
					Button1.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
					Button1.draw(Button1Quad.mouseOver() ? HoverColor : PrimaryColor);
					font(U"Explore").draw(88, Arg::leftCenter(80, 140), ColorF{ 0.4, 0.3, 0.2 });
					if (Button1Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Mission button
				{
					Button2.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
					Button2.draw(Button2Quad.mouseOver() ? HoverColor : PrimaryColor);
					font(U"Mission").draw(44, Arg::leftCenter(120, 310), ColorF{ 0.4, 0.3, 0.2 });
					compassIcon.drawAt(280, 310, ColorF{ 0.8 });
					if (Button2Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Formation button
				{
					Button3.movedBy(12, 10).draw(ColorF{ 0.5, 0.4, 0.3 });
					Button3.draw(Button3Quad.mouseOver() ? HoverColor : PrimaryColor);
					font(U"Formation").draw(44, Arg::leftCenter(380, 310), ColorF{ 0.4, 0.3, 0.2 });
					swordIcon.drawAt(540, 310, ColorF{ 0.8 });
					if (Button3Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Event area
				{
					Button4.draw(ColorF{ 0.2, 0.4, 0.6 });
					font(U"Event").draw(33, Arg::leftCenter(180, 415));
					if (Button4Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}
				}

				// Footer
				{
					Rect{ 60, 540, 540, 60 }.draw(ColorF{ 0.0, 0.6 });
					gemEmoji.scaled(0.36).drawAt(120, 570);
					font(U"67").draw(TextStyle::Outline(0.0, 0.2, ColorF{ 0.1 }), 36, Arg::leftCenter(150, 570));

					Circle{ Button5.center(), 20 }.draw(ColorF{ 0.2, 0.8 });
					plusIcon.drawAt(Button5.center(), Button5Quad.mouseOver() ? HoverColor : PrimaryColor);
					if (Button5Quad.mouseOver())
					{
						Cursor::RequestStyle(CursorStyle::Hand);
					}

					moneyEmoji.scaled(0.36).drawAt(300, 570);
					font(ThousandsSeparate(12345)).draw(TextStyle::Outline(0.0, 0.2, ColorF{ 0.1 }), 36, Arg::leftCenter(330, 570));
				}
			}

			// Complete the render texture contents by issuing 2D draw commands (Flush)
			// and resolving MSAA (Resolve)
			Graphics2D::Flush();
			renderTexture.resolve();
		}

		// Draw depth-style UI
		{
			// Shadow effect toward the right edge
			Rect{ 460, 0, 540, 600 }.draw(Arg::left = ColorF{ 0.0, 0.0 }, Arg::right = ColorF{ 0.0, 0.2 });

			// Apply sampler state suitable for QuadWarp (prevents roughness in reduced areas)
			const ScopedRenderStates2D sampler{ SamplerState::ClampAniso };

			// Project and draw render texture to TargetQuad
			Shader::QuadWarp(TargetQuad, renderTexture);
		}
	}
}