【Unity HDRP】Custom Pass 遮挡显示及描边效果

Posted by FlowingCrescent on 2021-06-04
Estimated Reading Time 17 Minutes
Words 4k In Total
Viewed Times

前言

本文初发布于zhihu
https://zhuanlan.zhihu.com/p/170241589

近日,笔者在进行项目开发时,美术提出了一个需求,要让主角Player有个描边,而且被建筑遮挡的时候能有斜线使其显示出来。
听到这个需求,笔者首先想到的是用后处理把两个效果都做掉,然后便想起了URP的Render Feature,因为笔者曾经看过以Render Feature实现遮挡半透明效果的教程。不过不同的是,我们的项目使用的是HDRP,而HDRP中与URP的Render Feature相对应的强大功能为Custom Pass
在学习相关内容时发现,目前关于Custom Pass的资料实在稀少,笔者便决定在自己完成效果并理解原理后写一篇文章作为笔记,也权且当做一个简单的Custom Pass入门教程提供给各位。

关于CustomPass

如上文所言,Custom Pass是个HDRP所独有的强大功能,它能够支持用户自定义Pass,并将其插入渲染管线的绝大部分位置。
而HDRP默认有两种Custom Pass,分别为DrawRenderersCustomPassFullScreenCustomPass
两者的用途都正如其名,前者为专门渲染某类物体的Pass,后者为全屏后处理的Pass。
about_bg2
而在这次的效果实现中,我们就是要使用这两个Pass来完成。
如果你想知道更多关于CustomPass的内容,可以参考官方文档:
https://docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@7.3/manual/Custom-Pass.html

效果实现原理

虽然Custom Pass是个新功能,但是我们效果的基本原理其实跟传统的后处理做法是一样的,具体可参考下文
https://zhuanlan.zhihu.com/p/98663995

简单来说,遮挡高亮的原理就是以两个深度图来进行对比,一个深度图所存的是Player的深度,一个是场景的深度,当场景的深度值大于Player的深度值(此时的深度图均为Reverse-Z的,即“近大远小”)时,我们即可判定Player被遮挡,便可以在这个像素上进行一些操作了。

而后处理描边的原理可以参考这篇文章,言简意赅地说,就是想办法对我们需要绘制描边的那些物体额外绘制出一个“纯色”(物体为纯白或其他颜色,背景为纯黑)的buffer,然后对那个纯色buffer做边缘检测来进行描边。
https://zhuanlan.zhihu.com/p/95747680
只是这篇文章是利用Stencil Buffer来进行纯色buffer的绘制,我们则直接使用CustomPass即可。
可以说,本文的方法是典型的新瓶装旧酒。

*笔者所用Unity版本为2019.4.6f1,HDRP 7.3.1

*笔者经验甚少,难以避免文中出现错误,还请大家不吝斧正,只求轻喷。

本文代码参考自:https://github.com/alelievr/HDRP-Custom-Passes
且使用了其中的InnerColor图片(被遮挡时的那个斜线Texture):image.png
这个工程中还有许多CustomPass的使用示例,相当值得学习。


绘制“纯色Buffer”及Player深度

那么我们正式动工。

在HDRP默认场景中创建一个Empty Object,然后给它添加Custom Pass Volume
image.png
之后它点击右下角的"+",选择DrawRenderersCustomPass,
image.png
我们暂时不去修改它的默认值。

那么以Create/Shader/HDRP/Custom Renderers Pass创建我们所需的Pass,并以它创建Material,放进刚刚创建的CustomPass的Overrides/Material中,当然,现在还没什么变化。

image.png

那么打开Shader,让我们来粗略地看一下它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
Shader "Renderers/NewRenderersCustomPass"
{
Properties
{
_Color("Color", Color) = (1,1,1,1)
_ColorMap("ColorMap", 2D) = "white" {}

// Transparency
_AlphaCutoff("Alpha Cutoff", Range(0.0, 1.0)) = 0.5
}

HLSLINCLUDE

#pragma target 4.5
#pragma only_renderers d3d11 ps4 xboxone vulkan metal switch

// #pragma enable_d3d11_debug_symbols

//enable GPU instancing support
#pragma multi_compile_instancing

ENDHLSL

SubShader
{
Pass
{
Name "FirstPass"
Tags { "LightMode" = "FirstPass" }

Blend Off
ZWrite Off
ZTest LEqual

Cull Back

HLSLPROGRAM

// Toggle the alpha test
#define _ALPHATEST_ON

// Toggle transparency
// #define _SURFACE_TYPE_TRANSPARENT

// Toggle fog on transparent
#define _ENABLE_FOG_ON_TRANSPARENT

// List all the attributes needed in your shader (will be passed to the vertex shader)
// you can see the complete list of these attributes in VaryingMesh.hlsl
#define ATTRIBUTES_NEED_TEXCOORD0
#define ATTRIBUTES_NEED_NORMAL
#define ATTRIBUTES_NEED_TANGENT

// List all the varyings needed in your fragment shader
#define VARYINGS_NEED_TEXCOORD0
#define VARYINGS_NEED_TANGENT_TO_WORLD

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/RenderPipeline/RenderPass/CustomPass/CustomPassRenderers.hlsl"

TEXTURE2D(_ColorMap);
float4 _ColorMap_ST;
float4 _Color;

// If you need to modify the vertex datas, you can uncomment this code
// Note: all the transformations here are done in object space
// #define HAVE_MESH_MODIFICATION
// AttributesMesh ApplyMeshModification(AttributesMesh input, float3 timeParameters)
// {
// input.positionOS += input.normalOS * 0.0001; // inflate a bit the mesh to avoid z-fight
// return input;
// }

// Put the code to render the objects in your custom pass in this function
void GetSurfaceAndBuiltinData(FragInputs fragInputs, float3 viewDirection, inout PositionInputs posInput, out SurfaceData surfaceData, out BuiltinData builtinData)
{
float2 colorMapUv = TRANSFORM_TEX(fragInputs.texCoord0.xy, _ColorMap);
float4 result = SAMPLE_TEXTURE2D(_ColorMap, s_trilinear_clamp_sampler, colorMapUv) * _Color;
float opacity = result.a;
float3 color = result.rgb;

#ifdef _ALPHATEST_ON
DoAlphaTest(opacity, _AlphaCutoff);
#endif

// Write back the data to the output structures
ZERO_INITIALIZE(BuiltinData, builtinData); // No call to InitBuiltinData as we don't have any lighting
builtinData.opacity = opacity;
builtinData.emissiveColor = float3(0, 0, 0);
surfaceData.color = color;
}

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/RenderPipeline/ShaderPass/ShaderPassForwardUnlit.hlsl"

#pragma vertex Vert
#pragma fragment Frag

ENDHLSL
}
}
}

粗略地看一看,就可以发现这个Shader的写法还是挺古怪的,颜色的输出在GetSurfaceAndBuiltinData()中,而具体的vert和frag都不涉及。
官方也在这个默认Shader中添加了许多注释,如果你想要更加深入地学习,推荐把注释都读一遍。

那么回想一下我们的目的,我们打算“将Player以白色渲染到一张黑底图片上”。那么写起来也非常简单了,我们只需要让输出的color永远为白色(1, 1, 1)即可。
因此,我们只需要将GetSurfaceAndBuiltinData()修改一下:

1
2
3
4
5
6
7
8
void GetSurfaceAndBuiltinData(FragInputs fragInputs, float3 viewDirection, inout PositionInputs posInput, out SurfaceData surfaceData, out BuiltinData builtinData)
{
// Write back the data to the output structures
ZERO_INITIALIZE(BuiltinData, builtinData); // No call to InitBuiltinData as we don't have any lighting
builtinData.opacity = 1;
builtinData.emissiveColor = float3(0, 0, 0);
surfaceData.color = _Color;
}

哦差点忘了,我们还没写入深度!要记得把Pass中的ZWrite Off改成Zwrite On~
(当然,我们可以尽可能地把Shader中没用到那些内容删除干净,不过这并不是本文的重点,就不多提了)
保存Shader之后,我们会发现场景内所有物体都变成了白色
image.png
由于一些后处理的存在,这些物体的颜色不是纯白

此时我们便需要调整Custom Pass中的一些设置了
image.png
将Target Color Buffer 和Target Depth Buffer都设置为Custom,前者的设置是因为我们要让物体的“白色”绘制在另一张图上,而Depth Buffer的设置便是“单独绘制Player深度”了,相当便利。
而ClearFlag的设置,就是让我们在每次调用这两个Buffer之前,都将这两个Buffer清空。
之后,我们便需要为Player单独创立一个层,并且将我们要当做Player的物体的层设置为"Player",我选择的是场景中的Workbench。
此时我们看场景,依旧与最初没有区别,但这并不代表我们之前是白干的,让我们打开Frame Debugger,找到Custom Pass的内容,便可以看见我们已经切实地“将Player以白色画在了黑底的图上”。
image.png

而Custom Depth Buffer在Frame Debugger中看不到,笔者这里通过RenderDoc来查看,可以看见Workbench的深度被单独绘制了。
image.png
而图像上下翻转,应当是DX与OpenGL的纹理坐标轴不同的缘故,无伤大雅。


绘制描边与遮挡显示

那么我们来到了第二步,为了让效果更明显,我们先放一个Plane挡住一半的Workbench,就跟头图一样。
和第一步类似,我们要先在Custom Pass中新建一个FullScreenCustomPass,然后新建一个Create/Shader/HDRP/Custom FullScreen Pass,并以其创建一个Material放入Custom Pass中,依旧没什么效果。
那么我们打开Shader看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
Shader "FullScreen/NewFullScreenCustomPass"
{
HLSLINCLUDE

#pragma vertex Vert

#pragma target 4.5
#pragma only_renderers d3d11 ps4 xboxone vulkan metal switch

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/RenderPipeline/RenderPass/CustomPass/CustomPassCommon.hlsl"

// The PositionInputs struct allow you to retrieve a lot of useful information for your fullScreenShader:
// struct PositionInputs
// {
// float3 positionWS; // World space position (could be camera-relative)
// float2 positionNDC; // Normalized screen coordinates within the viewport : [0, 1) (with the half-pixel offset)
// uint2 positionSS; // Screen space pixel coordinates : [0, NumPixels)
// uint2 tileCoord; // Screen tile coordinates : [0, NumTiles)
// float deviceDepth; // Depth from the depth buffer : [0, 1] (typically reversed)
// float linearDepth; // View space Z coordinate : [Near, Far]
// };

// To sample custom buffers, you have access to these functions:
// But be careful, on most platforms you can't sample to the bound color buffer. It means that you
// can't use the SampleCustomColor when the pass color buffer is set to custom (and same for camera the buffer).
// float4 SampleCustomColor(float2 uv);
// float4 LoadCustomColor(uint2 pixelCoords);
// float LoadCustomDepth(uint2 pixelCoords);
// float SampleCustomDepth(float2 uv);

// There are also a lot of utility function you can use inside Common.hlsl and Color.hlsl,
// you can check them out in the source code of the core SRP package.

float4 FullScreenPass(Varyings varyings) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(varyings);
float depth = LoadCameraDepth(varyings.positionCS.xy);
PositionInputs posInput = GetPositionInput(varyings.positionCS.xy, _ScreenSize.zw, depth, UNITY_MATRIX_I_VP, UNITY_MATRIX_V);
float3 viewDirection = GetWorldSpaceNormalizeViewDir(posInput.positionWS);
float4 color = float4(0.0, 0.0, 0.0, 0.0);

// Load the camera color buffer at the mip 0 if we're not at the before rendering injection point
if (_CustomPassInjectionPoint != CUSTOMPASSINJECTIONPOINT_BEFORE_RENDERING)
color = float4(CustomPassLoadCameraColor(varyings.positionCS.xy, 0), 1);

// Add your custom pass code here

// Fade value allow you to increase the strength of the effect while the camera gets closer to the custom pass volume
float f = 1 - abs(_FadeValue * 2 - 1);
return float4(color.rgb + f, color.a);
}

ENDHLSL

SubShader
{
Pass
{
Name "Custom Pass 0"

ZWrite Off
ZTest Always
Blend SrcAlpha OneMinusSrcAlpha
Cull Off

HLSLPROGRAM
#pragma fragment FullScreenPass
ENDHLSL
}
}
Fallback Off
}

和之前的Custom Renderers Pass写法很是相似,我们只需要关注FullScreenPass()即可,不过这次这个函数就是Fragment Shader。
笔者最初使用时以为跟以前的OnRenderImage一样,以为我们是拿到了后处理前的CameraTexture,然后在它的基础上进行处理。但是眼尖的朋友可以发现,这个Shader中有一个Blend SrcAlpha OneMinusSrcAlpha,那就说明我们渲染的东西是个半透明的Texture。
更通俗来讲,Custom Renderers Pass就是渲染出一个图片与我们的场景图混合,默认的混合方式就是这个透明度混合。

描边方式的选择

那么我们先进行描边操作,后处理描边可以使用Sobel/Prewitt等算子进行卷积计算,我最初也是那么打算的,之前那个使用Stencil Buffer做描边的文章也确实是那么做的,但是笔者参考了前言中的那个Github工程后发现,即便是只“上下左右采样四次取最大值”带来的效果也同样不错。
而这个似乎就叫Max Filter,也就是“对 data 进行滤波,用邻域 r 内的最大值替换图像中的值”。
但是如果只是如此,会导致物体内部一片纯色,因此的时候还需要减去原本Custom Color所绘制的纯色区域,留下的就是描边了。正因为还需要这一步,Max Filter并不能通用在大部分以法线或深度来检测边缘的场合,而反过来讲,Max Filter也是在本项目中的特化方法。
笔者将两个方法都做了一次:
image.png
可以看见,基本没什么区别,但如果仔细看的话,可以发现Sobel的描边会比Max的更圆滑一些,但要看出这个也确实是需要写轮眼的功力了,而且这也只是Max用上下左右4次采样而已,它也可以再加斜着的四个方向,还是比Sobel少采样一次呢。
不过,即便Max Filter比我们常用的Sobel Filter更风光,但别忘了我们还有Roberts算子(image.png),它也只用采样四次。那么同样的,笔者也尝试了一下:
image.png
显然,效果也不错
好吧,Roberts确实也不错,而它也不用减去纯色的那步骤……

即便如此,我相信以这种常见方式进行描边大家也都看腻了,笔者在此还是选择Max Filter,权当一次思维拓展吧。各位在实践的时候大可以选择以Roberts算子来描边。

正式动工

首先,我们要定义每次采样的不同方向。
以下的代码部分写在FullScreenPass之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#define c45 0.707107
#define c225 0.9238795
#define s225 0.3826834

#define MAXSAMPLES 16
static float2 offsets[MAXSAMPLES] = {
float2(1, 0),
float2(-1, 0),
float2(0, 1),
float2(0, -1),

float2(c45, c45),
float2(c45, -c45),
float2(-c45, c45),
float2(-c45, -c45),

float2(c225, s225),
float2(c225, -s225),
float2(-c225, s225),
float2(-c225, -s225),
float2(s225, c225),
float2(s225, -c225),
float2(-s225, c225),
float2(-s225, -c225)
};

offsets数组便是我们所存的各次采样的方向,前四个值代表上下右左四个方向,之后的四个为斜向45°的四个方向,c45即为cos(45°)。而c225则是代表cos(22.5°),s即为sin。这个采样数组的设置也是为了方便之后对采样次数的调节,我们接下来就马上会看到。

那么为了描边,我们需要一个调整采样次数的变量,以及调整描边宽度及描边颜色的变量
因此在开头添加Properties:

1
2
3
4
5
6
Properties
{
_SamplePrecision ("Sampling Precision", Range(1, 3)) = 1
_OutlineWidth ("Outline Width", Float) = 2
_OuterColor ("Outer Color", Color) = (1, 1, 0, 1)
}

记得要将它们再声明一次。

那么描边的内容比较少,也比较简单,大家光看注释相信也能懂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  float4 FullScreenPass(Varyings varyings): SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(varyings);
float depth = LoadCameraDepth(varyings.positionCS.xy);
PositionInputs posInput = GetPositionInput(varyings.positionCS.xy, _ScreenSize.zw, depth, UNITY_MATRIX_I_VP, UNITY_MATRIX_V);

//CustomColorBuffer中本次处理的像素的颜色值
float4 c = LoadCustomColor(posInput.positionSS);

//调整采样次数以4, 8, 16幂次增长
int sampleCount = min(2 * pow(2, _SamplePrecision), MAXSAMPLES) ;

//计算每像素之间的uv差
float2 uvOffsetPerPixel = 1.0 / _ScreenSize .xy;

float4 outline = 0;
for (uint i = 0; i < sampleCount; ++ i)
{
//取sampleCount次采样中的最大值
outline = max(SampleCustomColor(posInput.positionNDC + uvOffsetPerPixel * _OutlineWidth * offsets[i]), outline);
}

//去掉原本纯色块的部分
outline *= _OuterColor * (1 - c.a);

return outline;
}

要注意的是LoadCustomColor()使用的是positionSS(Screen Space),而SampleCustomColor()使用的是positionNDC。当然如果想用SampleCustomColor()取得该像素的颜色也是可以的,只是笔者这里示范一下两个函数的用法。

那么我们保存Shader,便可以看见场景中Player层的物体已经被描上一层黄边了。

接下来就是遮挡显示的内容了,为了实现被遮挡的内容,我们需要定义斜线填充图的颜色、斜线图的Texture以及这Texture的重复次数。

1
2
3
4
5
6
7
8
9
10
Properties
{
_SamplePrecision ("Sampling Precision", Range(1, 3)) = 1
_OutlineWidth ("Outline Width", Float) = 1
_OuterColor ("Outer Color", Color) = (1, 1, 0, 1)

_InnerColor ("Inner Color", Color) = (1, 1, 0, 1)
_Texture ("Texture", 2D) = "black" { }
_TextureSize ("Texture Pixels Size", float) = 32
}

实际的代码量同样很少,也比较简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
      ......
//非描边的内容用斜线填充了,则不再需要这步操作了
//outline *= _OuterColor * (1 - c.a);

//读取CustomDepthBuffer
float d = LoadCustomDepth(posInput.positionSS);

//进行深度的判断,如果判定为被遮挡则用我们设置的透明度,反之则为0。
//0.000001为bias,避免浮点数的精度问题导致的误差。
float alphaFactor = (depth > d + 0.000001) ?_BehindFactor: 0;
//对InnerColorTexture进行采样
float4 innerColor = SAMPLE_TEXTURE2D(_Texture, s_trilinear_repeat_sampler, posInput.positionSS / _TextureSize) * _InnerColor;

innerColor.a *= alphaFactor;

float4 output = 0;
//将描边赋值给output
output = lerp(output, _OuterColor * float4(outline.rgb, 1), outline.a);
//将纯色色块覆盖的区域以InnerColor替代
output = lerp(output, innerColor * float4(c.rgb, 1), c.a);

return output;

那么保存Shader之后,就大功告成了。
image.png
笔者把遮挡颜色调为了橙色,效果还是不错的

如果之后美术提出,要让敌人也有描边,而且描边颜色还得不同,那简直是轻而易举,相信读完本文的人是一定会做的。
image.png

总结

做了这个简单的案例之后,我们便学会了Custom Pass的基本用法,也了解了它的基本原理,虽然它显得很高大上,不过用起来其实还是比较方便的。
希望这篇文章能够对将来学习HDRP Custom Pass相关内容的人有所帮助。
也感谢在此领域不断开拓的前人,让我们能够比较容易地学到这些内容。

参考资料

https://docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@7.3/manual/Custom-Pass.html
https://www.bilibili.com/video/BV1iE411i7Ei
https://zhuanlan.zhihu.com/p/98663995
https://zhuanlan.zhihu.com/p/95747680
https://github.com/alelievr/HDRP-Custom-Passes
https://reference.wolfram.com/language/ref/MaxFilter.html


感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。