DirectX11学习笔记十二 高斯模糊描边

感谢Chili的教程
这一节属于PostProcessing,需要用到RenderToTexture和stencil的知识。

RenderToTexture

复习一下渲染物体可能需要绑定的组件:

组件名称 用途
ID3D11InputLayout 定义VertexBuffer的输入格式,比如Pos,TexCoord
ID3D11Buffer(as VertexBuffer) 存储要输入的顶点数据结构,坐标法线etc.
ID3D11Buffer(as IndexBuffer) 存储顶点渲染的顺序
ID3D11Buffer(as VS/PSConstantBuffer) 常量缓冲区
D3D11_PRIMITIVE_TOPOLOGY 顶点的连接方法(游戏默认三角形)
ID3D11PixelShader 像素着色器
ID3D11VertexShader 顶点着色器
ID3D11ShaderResourceView(refer to Texture2D) 2D纹理资源
ID3D11SamplerState 纹理采样器
ID3D11BlendState 像素混合
ID3D11RasterizerState 光栅化状态
*ID3D11RenderTargetView *渲染目标
*ID3D11DepthStencilView *深度模版缓冲
*D3D11_VIEWPORT *视口,显示的区域大小

其中,渲染目标就是定义的整个渲染管线的输出目标(纹理),复习一下RenderTarget的定义的过程。

// create texture resource
	D3D11_TEXTURE2D_DESC textureDesc = {};
	// ...
	wrl::ComPtr<ID3D11Texture2D> pTexture;
	GFX_THROW_INFO(GetDevice(gfx)->CreateTexture2D(
		&textureDesc, nullptr, &pTexture
	));

	// create the resource view on the texture
	D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
	// ...
	GFX_THROW_INFO(GetDevice(gfx)->CreateShaderResourceView(
		pTexture.Get(), &srvDesc, &pTextureView
	));

	// create the target view on the texture
	D3D11_RENDER_TARGET_VIEW_DESC rtvDesc = {};
	// ...
	GFX_THROW_INFO(GetDevice(gfx)->CreateRenderTargetView(
		pTexture.Get(), &rtvDesc, &pTargetView
	));

RenderTarget的目标是一张纹理,渲染结果即保存到该纹理上,如果想将渲染结果用作其他,那么用已经跟纹理绑定好的ShaderResourceView取出即可。最后屏幕上显示的画面取决于交换链present时绑定的renderTarget。
那么一个可能的RenderTarget类可以这么写

// h
private:
	UINT width;
	UINT height;
	Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> pTextureView;
	Microsoft::WRL::ComPtr<ID3D11RenderTargetView> pTargetView;

// cpp
RenderTarget::RenderTarget(Graphics& gfx, UINT width, UINT height)
	:
	width(width),
	height(height)
{
	// create texture resource
	D3D11_TEXTURE2D_DESC textureDesc = {};
	textureDesc.Width = width;
	textureDesc.Height = height;
	textureDesc.MipLevels = 1;
	textureDesc.ArraySize = 1;
	textureDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;  // 
	textureDesc.SampleDesc.Count = 1;
	textureDesc.SampleDesc.Quality = 0;
	textureDesc.Usage = D3D11_USAGE_DEFAULT;
	textureDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; // the former must exist for using RTT
	textureDesc.CPUAccessFlags = 0;
	textureDesc.MiscFlags = 0;
	wrl::ComPtr<ID3D11Texture2D> pTexture;
	GetDevice(gfx)->CreateTexture2D(
		&textureDesc, nullptr, &pTexture
	);

	// create the resource view on the texture
	D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
	srvDesc.Format = textureDesc.Format;
	srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
	srvDesc.Texture2D.MostDetailedMip = 0;
	srvDesc.Texture2D.MipLevels = 1;
	GetDevice(gfx)->CreateShaderResourceView(
		pTexture.Get(), &srvDesc, &pTextureView
	);

	// create the target view on the texture
	D3D11_RENDER_TARGET_VIEW_DESC rtvDesc = {};
	rtvDesc.Format = textureDesc.Format;
	rtvDesc.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D;
	rtvDesc.Texture2D = D3D11_TEX2D_RTV{ 0 };
	GetDevice(gfx)->CreateRenderTargetView(
		pTexture.Get(), &rtvDesc, &pTargetView
	);
}

void RenderTarget::BindAsTexture(Graphics& gfx, UINT slot) const noexcept
{
	GetContext(gfx)->PSSetShaderResources(slot, 1, pTextureView.GetAddressOf());
}

void RenderTarget::BindAsTarget(Graphics& gfx) const noexcept
{
	GetContext(gfx)->OMSetRenderTargets(1, pTargetView.GetAddressOf(), nullptr);

	// configure viewport
	D3D11_VIEWPORT vp;
	vp.Width = (float)width;
	vp.Height = (float)height;
	vp.MinDepth = 0.0f;
	vp.MaxDepth = 1.0f;
	vp.TopLeftX = 0.0f;
	vp.TopLeftY = 0.0f;
	GetContext(gfx)->RSSetViewports(1u, &vp);
}

void RenderTarget::Clear(Graphics& gfx, const std::array<float, 4>& color) const noexcept
{
	GetContext(gfx)->ClearRenderTargetView(pTargetView.Get(), color.data());
}

描边

描边的方法有很多,最简单的办法应该是

一. Scale Up

  1. 正常渲染scene
  2. 第二次关掉深度测试,将mesh重新绘制,在mesh同样的位置写入模版值ref,不写入颜色值(PS设为nullptr)
  3. 第三次关掉深度测试,将mesh的scale放大一点,与模版值ref比较,不相等的位置就填入solid颜色,重新绘制mesh
    模版缓冲区的两种模式代码示例
	if (mode == Mode::Write)
	{
		dsDesc.DepthEnable = FALSE;
		dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO;
		dsDesc.StencilEnable = TRUE;
		dsDesc.StencilWriteMask = 0xFF;
		dsDesc.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS;
		dsDesc.FrontFace.StencilPassOp = D3D11_STENCIL_OP_REPLACE;
	}
	else if (mode == Mode::Mask)
	{
		dsDesc.DepthEnable = FALSE;
		dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO;
		dsDesc.StencilEnable = TRUE;
		dsDesc.StencilReadMask = 0xFF;
		dsDesc.FrontFace.StencilFunc = D3D11_COMPARISON_NOT_EQUAL;
		dsDesc.FrontFace.StencilPassOp = D3D11_STENCIL_OP_KEEP;
	}

隐藏bug:缩放的本质是坐标做乘法,如果mesh的中心偏移,那么缩放就会出问题。
DirectX11学习笔记十二 高斯模糊描边_第1张图片

二. Offset

沿着法线移动顶点
简单无脑

// VS
cbuffer Offset
{
    float offset;
}

float4 main(float3 pos : Position, float3 n : Normal) : SV_Position
{
    return mul(float4(pos + n * offset, 1.0f), modelViewProj);
}

隐藏bug:对于quad这种两个三角形拼起来的活在二维世界里的物体,沿着法线移动只能导致outline单向平移

三 后处理

一二两种办法都是在几何上处理,弊端是当摄像机远离mesh时,透视会导致outline变细,甚至完全消失,如果能屏幕空间上做描边,outline就不会受透视影响了。办法就是将mesh离屏渲染成solid色,做模糊后,该颜色团会变大一点,产生“光辉”,再用mesh盖住没有变大的部分。
得到纹理后用CPU逐像素扫描肯定是没GPU快的,所以不如把纹理放到跟屏幕一样大的quad上再渲染一遍,同时做后处理效果。
方法:

  1. 绑定renderTargetMainScene。
  2. 正常渲染mesh。
  3. 再次渲染mesh,但不着色,只将该区域的模版值设为ref,并缓存该模版缓冲区。
  4. 绑定renderTarget1,重新用solid颜色渲染mesh。
  5. 绑定renderTarget2,将renderTarget1中绑定的ShaderResourceView作为纹理资源传入着色器。
    将两个2D三角形{(-1,1),(1,1),(-1,-1),(1,-1),VS里将z设为0}拼成正方形quad,完整的糊在屏幕上,以renderTarget1为纹理,做后处理。
  6. 坐标转换,为了将纹理正确的采样到屏幕(屏幕坐标[-1,1])上,需要将屏幕上的坐标映射到UV坐标系中,以获取正确的纹理坐标
    DirectX11学习笔记十二 高斯模糊描边_第2张图片
    U = ( x + 1 ) / 2
    V =-( y - 1 ) / 2
// VS
struct VSOut
{
    float2 uv : Texcoord;
    float4 pos : SV_Position;
};

VSOut main(float2 pos : Position)
{
    VSOut vso;
    vso.pos = float4(pos, 0.0f, 1.0f);
    vso.uv = float2((pos.x + 1) / 2.0f, -(pos.y - 1) / 2.0f);
    return vso;
}
  1. PS使用高斯模糊
    高斯模糊的原理跟方框滤波类似,方框正中间代表要模糊的像素,周围的值用来计算带权平均值,权值服从高斯分布而不是方框滤波那种权值全都一样的排布。
    DirectX11学习笔记十二 高斯模糊描边_第3张图片
    DirectX11学习笔记十二 高斯模糊描边_第4张图片
    函数表达式,在这里插入图片描述
    为了优化计算,取代NxN的方框kernel计算,使用1xN的kernel先计算竖直方向,再计算水平方向。
// 1D kernel 
	template<typename T>
	constexpr T gauss(T x, T sigma) noexcept
	{
		const auto ss = sq(sigma);
		return ((T)1.0 / sqrt((T)2.0 * (T)PI_D * ss)) * exp(-sq(x) / ((T)2.0 * ss));
	}
	void SetKernelGauss(Graphics& gfx, int radius, float sigma) noxnd
	{
		assert(radius <= maxRadius);
		Kernel k;
		k.nTaps = radius * 2 + 1;  // total slot count
		float sum = 0.0f;
		for (int i = 0; i < k.nTaps; i++)
		{
			const auto x = float(i - radius);
			const auto g = gauss(x, sigma);
			sum += g;
			k.coefficients[i].x = g;
		}
		for (int i = 0; i < k.nTaps; i++)
		{
			k.coefficients[i].x /= sum;  // Must divide sum in case dest Color could be brighter than original
		}
		// ... Bind Kernel as PSConstantBuffer
	}

得到kernel后,在像素着色器中获取该像素周围的颜色进行代权平均即可,那么问题来了,既然uv都是标准化的[0,1],怎么获取该像素周围的元素呢?
假如renderTarget的纹理是800*600,那么可以认为右边的像素值 U = u + 1/800,上边第二个 V = v - 2/600。
隐藏bug:当像素处于边界时,这样取样会导致溢出,将纹理采样器的AddressUV设为D3D11_TEXTURE_ADDRESS_MIRROR即可。

Texture2D tex;
SamplerState splr;

cbuffer Kernel
{
    uint nTaps;
    float coefficients[15];
}

cbuffer Control
{
    bool horizontal;
}

float4 main(float2 uv : Texcoord) : SV_Target
{
    uint width, height;
    tex.GetDimensions(width, height);
    float dx, dy;
    if (horizontal)
    {
        dx = 1.0f / width;
        dy = 0.0f;
    }
    else
    {
        dx = 0.0f;
        dy = 1.0f / height;
    }
    const int r = nTaps / 2;

    float4 acc = { 0.0f, 0.0f, 0.0f, 0.0f };
    for (int i = -r; i <= r; i++)
    {
        const float2 tc = uv + float2(dx * i, dy * i);
        const float4 s = tex.Sample(splr, tc).rgba;
        const float coef = coefficients[i + r];
        acc += s * coef;
    }
    return acc;
}
  1. 渲染两次模糊,作为一种优化,因为如果是按照矩阵卷积的话,对于3x3的核+800x600分辨率,需要计算9x800x600;如果分成1x3的核和3x1的核对一张图片模糊两次,那就是计算3x800x600x2。当kernel变得很大时,可以减少大量运算。
    第一次渲染模糊完,将当时绑定的renderTarget2作为第二次用于计算模糊的纹理,绑定renderTargetMainScene,缓存的模版缓冲区重新绑定,开启blend,只在以前mesh没标记过的地方着色,未着色部分用混合弄掉,那么高斯模糊时略微扩大的“光环”就环绕在mesh周围了。
Texture2D tex;
SamplerState splr;

cbuffer Kernel
{
    uint nTaps;
    float coefficients[15];
}

cbuffer Control
{
    bool horizontal;
}

float4 main(float2 uv : Texcoord) : SV_Target
{
    uint width, height;
    tex.GetDimensions(width, height);
    float dx, dy;
    if (horizontal)
    {
        dx = 1.0f / width;
        dy = 0.0f;
    }
    else
    {
        dx = 0.0f;
        dy = 1.0f / height;
    }
    const int r = nTaps / 2;
    
    float4 acc = float4(0, 0, 0, 0);
    for (int i = -r; i <= r; i++)
    {
        const float2 tc = uv + float2(dx * i, dy * i);
        const float4 s = tex.Sample(splr, tc).rgba;
        const float coef = coefficients[i + r];
        acc += s * coef;
    }
    return acc;
}

DirectX11学习笔记十二 高斯模糊描边_第5张图片
结果的outline会变得非常细,因为混合的时候,blur kernel会将周围的黑色一起算到模糊的像素导致像素变黑,所以我们可以在混合的时候,将最亮的颜色取出来,用于混合,只对alpha做模糊。

	float accAlpha = 0.0f;
    float3 maxColor = float3(0.0f, 0.0f, 0.0f);
    for (int i = -r; i <= r; i++)
    {
        const float2 tc = uv + float2(dx * i, dy * i);
        const float4 s = tex.Sample(splr, tc).rgba;
        const float coef = coefficients[i + r];
        accAlpha += s.a * coef;
        maxColor = max(s.rgb, maxColor);
    }
    return float4(maxColor, accAlpha);

DirectX11学习笔记十二 高斯模糊描边_第6张图片
大功告成
具体高斯模糊描边流程伪代码

	renderTargetAndStencil.Clear(); // main scene
	renderTarget1.Clear(); 
	renderTarget2.Clear(); 
	
	// Pass 1
	renderTargetAndStencil.BindAsTarget();
	stencil.Set(Off);
	normalScene.Draw();
	
	// Pass 2
	stencil.Set(WriteMask);
	someMeshInScene.DrawWithoutPS();
	
	// Pass 3
	renderTarget1.BindAsTarget();
	stencil.Set(Off);
	someMeshInScene.DrawSolid();
	
	// Post process verticlly
	renderTarget2.BindAsTarget();
	renderTarget1.BindAsTexture();
	psConstantBuffer.Update(vertical,kernel);
	fullScreenQuad.Draw();
	
	// Post process horizontally and blend	
	renderTargetAndStencil.BindAsTarget();
	renderTarget2.BindAsTexture();
	psConstantBuffer.Update(horizontal,kernel);
	stencil.Set(DrawOutsideMask);
	fullScreenQuad.Draw();

后处理优化

后处理不需要处理完整的屏幕分辨率,可以处理低分辨率版的纹理再拉伸到正常大小。
renderTarget绑定的同时设置视口大小。使用rt1和rt2用于离屏渲染时,将其视口变小,但是得到的纹理在规范化坐标系下跟渲染主场景使用的RenderTaget并没有大小的区别。相当于使用更小的分辨率获得了类似的效果。

void RenderTarget::BindAsTarget(Graphics& gfx, const UINT div) const noexcept
{
	GetContext(gfx)->OMSetRenderTargets(1, pTargetView.GetAddressOf(), nullptr);

	// configure viewport
	D3D11_VIEWPORT vp;
	vp.Width = (float)width / (float)div;
	vp.Height = (float)height / (float)div;
	vp.MinDepth = 0.0f;
	vp.MaxDepth = 1.0f;
	vp.TopLeftX = 0.0f;
	vp.TopLeftY = 0.0f;
	GetContext(gfx)->RSSetViewports(1u, &vp);
}

你可能感兴趣的:(DirectX)