DirectX Paint and WinUI 3.0

Merging Down

Drawing instances forever would not be a smart plan, because then we would need an instance buffer with an infinite size, which could get really resource intensive, even if we wanted to do that. What we can do instead is to only draw instances during the current frame and then at the end of the frame, merge all the drawn instances down to a single texture. It's like taking a photo of all the different stamps and just handing the photo to the GPU to draw. This can have its own drawbacks if there are a ton of layers that all need their own textures and the layer sizes are massive, but there are ways to manage that, as well. It's better than the alternative.

Layer List

First thing we need is something to hold the data of each of our layers. Let's create a new class for this. It'll have a 2D texture, which holds the merged down version of the layer's content, a render target view, that allows to write into that texture, and a shader resource view, that allows to read from that texture.

C#
public class Layer
{
	public ID3D11Texture2D Texture { get; set; }
	public ID3D11RenderTargetView RenderTargetView { get; set; }
	public ID3D11ShaderResourceView ShaderResourceView { get; set; }
}

Then, let's create a list for all our layers, and a variable to keep track of which layer we're currently working on.

C#
private List<Layer> layers = new List<Layer>();
private int activeLayer;

With the list in place, we can start creating layers. Let's create a new method for this called CreateLayer.

C#
public void CreateLayer()
{
	Layer layer = new Layer();
	Texture2DDescription layerTextureDescription = new Texture2DDescription()
	{
		Width = (int)SwapChainCanvas.Width,
		Height = (int)SwapChainCanvas.Height,
		MipLevels = 1,
		ArraySize = 1,
		Format = Format.B8G8R8A8_UNorm,
		SampleDescription = new SampleDescription(1, 0),
		Usage = ResourceUsage.Default,
		BindFlags = BindFlags.RenderTarget | BindFlags.ShaderResource,
		CPUAccessFlags = CpuAccessFlags.None,
		MiscFlags = ResourceOptionFlags.None
	};
	layer.Texture = device.CreateTexture2D(layerTextureDescription);
	layer.RenderTargetView = device.CreateRenderTargetView(layer.Texture);
	ShaderResourceViewDescription shaderResourceViewDesc = new ShaderResourceViewDescription()
	{
		Format = layerTextureDescription.Format,
		ViewDimension = ShaderResourceViewDimension.Texture2D
		Texture2D = new Texture2DShaderResourceView()
		{
			MipLevels = 1,
			MostDetailedMip = 0
		}
	};
	layer.ShaderResourceView = device.CreateShaderResourceView(layer.Texture, shaderResourceViewDesc);
	layers.Add(layer);
}

It's nothing you haven't seen before. We're just setting up the texture, the render target view, and the shader resource view for the layer. Finally, we're adding the new layer to the list of layers.

When that's done, we can call the CreateLayer method in the CreateResources method. Let's just make one for now and set that as our active layer.

C#
private void CreateResources()	
{
	...
	CreateLayer();
	activeLayer = 0;
}

Fullscreen Quad

The layers will be merged down one at a time. And since each layer will be the size of the whole screen (or view), we're going to have to draw each pixel of the screen for each layer to get them to a single 2d texture. For that, we'll need a new quad, a fullscreen quad, that covers the whole view. It'll be set up similarly to the old quad, but for clarity, I'd rather make a completely new one, so that if we need to modify them in the future, there won't be any problems.

C#
private Vertex[] instanceVertices;
private Vertex[] fullscreenVertices;
private uint[] quadIndices;
private uint[] fullscreenIndices;

private void CreateResources()
{
	instanceVertices = new Vertex[]
	{
		new Vertex() { Position = new Vector3(-1.0f,  1.0f, 0.0f), UV = new Vector2(0.0f, 0.0f) },
		new Vertex() { Position = new Vector3( 1.0f,  1.0f, 0.0f), UV = new Vector2(1.0f, 0.0f) },
		new Vertex() { Position = new Vector3( 1.0f, -1.0f, 0.0f), UV = new Vector2(1.0f, 1.0f) },
		new Vertex() { Position = new Vector3(-1.0f, -1.0f, 0.0f), UV = new Vector2(0.0f, 1.0f) }
	};

	fullscreenVertices = new Vertex[]
	{
		new Vertex() { Position = new Vector3(-1.0f,  1.0f, 0.0f), UV = new Vector2(0.0f, 0.0f) },
		new Vertex() { Position = new Vector3( 1.0f,  1.0f, 0.0f), UV = new Vector2(1.0f, 0.0f) },
		new Vertex() { Position = new Vector3( 1.0f, -1.0f, 0.0f), UV = new Vector2(1.0f, 1.0f) },
		new Vertex() { Position = new Vector3(-1.0f, -1.0f, 0.0f), UV = new Vector2(0.0f, 1.0f) }
	};

	quadIndices = new uint[]
	{
		0, 1, 2,
		0, 2, 3
	};

	fullscreenIndices = new uint[]
	{
		0, 1, 2,
		0, 2, 3
	};
	...
}

So, the old verticeArray and indiceArray are renamed to accommodate the instances, and we make similar arrays for the fullscreen quad. For fullscreen rendering, you could also get away with just doing a single triangle, but a quad is a bit easier to conceptualize, so let's stick with that one for now.

Shaders

Drawing instances and drawing the complete layer to a texture are two different functions. It makes sense to separate them into two different shaders. Let's replace our old shader files initialization with this.

C#
private ID3D11VertexShader vertexInstances;
private ID3D11PixelShader pixelInstances;
private ID3D11VertexShader vertexMerger;
private ID3D11PixelShader pixelMerger;

private void CreateShaders()
{
	var vertexEntryPoint = "VS";
	var vertexProfile = "vs_5_0";
	string vertexInstancesFile = Path.Combine(AppContext.BaseDirectory, "VertexInstances.hlsl");
	string vertexMergerFile = Path.Combine(AppContext.BaseDirectory, "VertexMerger.hlsl");

	var pixelEntryPoint = "PS";
	var pixelProfile = "ps_5_0";
	string pixelInstancesFile = Path.Combine(AppContext.BaseDirectory, "PixelInstances.hlsl");
	string pixelMergerFile = Path.Combine(AppContext.BaseDirectory, "PixelMerger.hlsl");

	ReadOnlyMemory<byte> vertexInstancesByteCode = Compiler.CompileFromFile(vertexInstancesFile, vertexEntryPoint, vertexProfile);
	ReadOnlyMemory<byte> pixelInstancesByteCode = Compiler.CompileFromFile(pixelInstancesFile, pixelEntryPoint, pixelProfile);
	ReadOnlyMemory<byte> vertexMergerByteCode = Compiler.CompileFromFile(vertexMergerFile, vertexEntryPoint, vertexProfile);
	ReadOnlyMemory<byte> pixelMergerByteCode = Compiler.CompileFromFile(pixelMergerFile, pixelEntryPoint, pixelProfile);

	vertexInstances = device.CreateVertexShader(vertexInstancesByteCode.Span);
	pixelInstances = device.CreatePixelShader(pixelInstancesByteCode.Span);
	vertexMerger = device.CreateVertexShader(vertexMergerByteCode.Span);
	pixelMerger = device.CreatePixelShader(pixelMergerByteCode.Span);
	...
}

Now we have two vertex shaders and two pixel shaders, a pair for instances and a pair for merging the view down. Similarly, we'll create input layouts for both the instance shader and the merger shader.

C#
private ID3D11InputLayout instancesInputLayout;
private ID3D11InputLayout mergerInputLayout;

private void CreateShaders()
{
	...
	InputElementDescription[] instancesInputElements = new InputElementDescription[]
	{
		new InputElementDescription("POSITION", 0, Format.R32G32B32_Float, 0, 0, InputClassification.PerVertexData, 0),
		new InputElementDescription("TEXCOORD", 0, Format.R32G32_Float, 12, 0, InputClassification.PerVertexData, 0),
		new InputElementDescription("POSITION", 1, Format.R32G32_Float, 0, 1, InputClassification.PerInstanceData, 1),
		new InputElementDescription("TEXCOORD", 1, Format.R32G32_Float, 8, 1, InputClassification.PerInstanceData, 1)
	};

	InputElementDescription[] mergerInputElements = new InputElementDescription[]
	{
		new InputElementDescription("POSITION", 0, Format.R32G32B32_Float, 0, 0, InputClassification.PerVertexData, 0),
		new InputElementDescription("TEXCOORD", 0, Format.R32G32_Float, 12, 0, InputClassification.PerVertexData, 0),
	};

	instancesInputLayout = device.CreateInputLayout(instancesInputElements, vertexInstancesByteCode.Span);
	mergerInputLayout = device.CreateInputLayout(mergerInputElements, vertexMergerByteCode.Span);
	...
}

And to pass the data to the vertex shaders, we'll need to replace our vertex and index buffers.

C#
private ID3D11Buffer instanceVertexBuffer;
private ID3D11Buffer instanceIndexBuffer;
private ID3D11Buffer fullscreenVertexBuffer;
private ID3D11Buffer fullscreenIndexBuffer;

private void CreateBuffers()
{
	unsafe
	{
		BufferDescription vertexBufferDesc = new BufferDescription()
		{
			Usage = ResourceUsage.Default,
			ByteWidth = sizeof(Vertex) * instanceVertices.Length,
			BindFlags = BindFlags.VertexBuffer,
			CPUAccessFlags = CpuAccessFlags.None
		};
		using DataStream dsVertex = DataStream.Create(instanceVertices, true, true);
		instanceVertexBuffer = device.CreateBuffer(vertexBufferDesc, dsVertex);
	}

	unsafe
	{
		BufferDescription fullscreenQuadBufferDesc = new BufferDescription()
		{
			Usage = ResourceUsage.Default,
			ByteWidth = sizeof(Vertex) * fullscreenVertices.Length,
			BindFlags = BindFlags.VertexBuffer,
			CPUAccessFlags = CpuAccessFlags.None
		};
		using DataStream dsVertex = DataStream.Create(fullscreenVertices, true, true);
		fullscreenVertexBuffer = device.CreateBuffer(fullscreenQuadBufferDesc, dsVertex);
	}

	BufferDescription indexBufferDesc = new BufferDescription
	{
		Usage = ResourceUsage.Default,
		ByteWidth = sizeof(uint) * quadIndices.Length,
		BindFlags = BindFlags.IndexBuffer,
		CPUAccessFlags = CpuAccessFlags.None,
	};
	using DataStream dsIndex = DataStream.Create(quadIndices, true, true);
	instanceIndexBuffer = device.CreateBuffer(indexBufferDesc, dsIndex);

	BufferDescription fullscreenIndexBufferDesc = new BufferDescription
	{
		Usage = ResourceUsage.Default,
		ByteWidth = sizeof(uint) * fullscreenIndices.Length,
		BindFlags = BindFlags.IndexBuffer,
		CPUAccessFlags = CpuAccessFlags.None,
	};
	using DataStream dsIndex2 = DataStream.Create(fullscreenIndices, true, true);
	fullscreenIndexBuffer = device.CreateBuffer(fullscreenIndexBufferDesc, dsIndex2);
	...
}

We'll be doing a lot of state changes in the Draw method, so we can remove a bunch of stuff from SetRenderState here.

C#
public void SetRenderState()
{
	// Input Assembler
	deviceContext.IASetPrimitiveTopology(PrimitiveTopology.TriangleList);

	// Vertex Shader
	deviceContext.VSSetConstantBuffer(0, constantBuffer);

	// Rasterizer Stage
	deviceContext.RSSetViewports(new Viewport[] { viewport });
	deviceContext.RSSetState(rasterizerState);

	// Pixel Shader	
	deviceContext.PSSetConstantBuffer(0, constantBuffer);

	// Output Merger
	deviceContext.OMSetDepthStencilState(depthStencilState, 1);
}

Now that we've set everything up, we can do the drawing in the next part.