Unity中使用URP实现屏幕空间下贴花渲染
前言
最近我在研究贴花(Decal)效果的实现。但是网上的文章我看得云里雾里。最终我看了网上的一部分的文章,加上我自己的思索后,我才搞懂如何去实现。所以这里我写这篇文章做一个笔记,也是为了防止我自己忘记如何实现。这里我还要说明一下,文章链接里的文章少,是因为当我想要写这篇文章的时候,大多数我看过的文章已经找不到了。
环境
Unity2022.3.8f1c1
Windows10
Universal RP 14.0.8
屏幕空间贴花(Screen Space Decals,SSD)
屏幕空间贴花(ScreenSpace Decal)是一种在游戏和图形渲染中常用的技术,它允许开发者在物体表面上动态添加图案或纹理,而无需改变原始模型的几何结构。这种技术在现代游戏中非常常见,用于实现各种视觉效果,如涂鸦、弹孔、纹身等。以上来自文言一心(百度AI)的介绍。
过程
我们使用立方体框选出一个区域。在我们限定的区域内,我们对深度图进行采样得到区域内的深度信息。然后我们将得到的深度信息与立方体的顶点结合并转换至模型空间中。接下来我们判断转换后的顶点是否还在立方体中,如果还在立方体中,那么我们直接用顶点的xy对立方体进行贴图采样并渲染出来。
我认为整个过程中最难理解的地方就在于如何将深度信息和立方体的顶点结合,我希望我的说法能让你们明白这一个过程。
将深度信息和立方体的顶点结合
首先我们要知道因为我们使用了一个立方体去框选出一个区域,所以我们再看这个立方体中的物体时,实际上是透过这个立方体的顶点看向物体所对应的顶点。如下图所示:
红色的线表示摄像机看向立方体顶点的方向,绿色是立方体的范围。
那么我们就应该将物体上的顶点映射到立方体上的顶点。现在的问题就变成了,我们如何在渲染立方体的Shader时是得到物体上的顶点信息。其实我们可以通过构建相似三角形来求出现在立方体上的顶点对应的物体顶点位置。如下图所示:
图中黑色的线和红色的线构成了相似三角形。
图中线段AB的值其实就是物体上的顶点到摄像机的距离,即深度depth,可由深度图得出。
A
D
⃗
\vec{AD}
AD可由世界空间(World Space)下的相机位置减去世界空间下的立方体顶点位置得出,则C点的信息我们也可以得出来。这里我设CamPos为摄像机在世界坐标的位置。则C点的位置CPos为(下文公式中的depth值默认已经转换到世界坐标系下了):
C
P
o
s
=
A
D
⃗
A
D
⃗
.
z
∗
d
e
p
t
h
+
C
a
m
P
o
s
CPos = \frac{\vec{AD}}{\vec{AD}.z} * depth + CamPos
CPos=AD.zAD∗depth+CamPos
不过在我们具体实现的时候,我们并不会在世界坐标系中进行上诉计算。实际上,我们会将这些转换到模型坐标系(Object Space)进行操作,这样方便我们进行贴图的采样。
具体实现
Shader "Test/DecalShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
[HDR]_Color("_Color", Color) = (1,1,1,1)
[Header(Cull)]
// https://docs.unity.cn/cn/2021.3/ScriptReference/Rendering.CullMode.html
[Enum(UnityEngine.Rendering.CullMode)]_Cull("Cull Type (default = Off)", Float) = 0
[Header(Need To Clip)]
[Toggle(_ProjectionAngleDiscardEnable)] _ProjectionAngleDiscardEnable("_ProjectionAngleDiscardEnable (default = off)", float) = 0
_ProjectionAngleDiscardThreshold("_ProjectionAngleDiscardThreshold", range(-1,1)) = 0
[Header(Blending)]
// https://docs.unity.cn/cn/2021.3/ScriptReference/Rendering.BlendMode.html
[Enum(UnityEngine.Rendering.BlendMode)]_DecalSrcBlend("_DecalSrcBlend (default = SrcAlpha)", Int) = 5 // 5 = SrcAlpha
[Enum(UnityEngine.Rendering.BlendMode)]_DecalDstBlend("_DecalDstBlend (default = OneMinusSrcAlpha)", Int) = 10 // 10 = OneMinusSrcAlpha
[Header(ZTest)]
// https://docs.unity.cn/cn/2021.3/ScriptReference/Rendering.CompareFunction.html
[Enum(UnityEngine.Rendering.CompareFunction)]_ZTest("Clip Type (default = Disable", Float) = 0
}
SubShader
{
Tags { "RenderType" = "Overlay" "Queue" = "Transparent-499" "DisableBatching" = "True" }
Pass
{
Cull [_Cull]
ZWrite off
ZTest [_ZTest]
Blend[_DecalSrcBlend][_DecalDstBlend]
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma shader_feature_local_fragment _ProjectionAngleDiscardEnable
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareNormalsTexture.hlsl"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float4 vertex : SV_POSITION;
float4 screenPos : TEXCOORD0;
// 在模型空间下的摄像机到顶点的方向
float4 viewRayOS : TEXCOORD1;
// 在模型空间下的摄像机的位置
float4 cameraPosOS : TEXCOORD2;
#if _ProjectionAngleDiscardEnable
// 盒子的方向
float4 orientation: TEXCOORD3;
#endif
};
CBUFFER_START(UnityPerMaterial)
sampler2D _MainTex;
float4 _MainTex_ST;
float4 _Color;
float _ProjectionAngleDiscardThreshold;
CBUFFER_END
v2f vert (appdata v)
{
v2f o;
VertexPositionInputs vertexPositionInput = GetVertexPositionInputs(v.vertex.xyz);
o.vertex = vertexPositionInput.positionCS;
o.screenPos = ComputeScreenPos(o.vertex);
// 获取到观察空间的位置
float3 viewRay = vertexPositionInput.positionVS;
o.viewRayOS.w = viewRay.z;
// unity的相机空间是右手线(负轴指向屏幕),我们想要片段着色器中的正z射线,所以对其取反
viewRay *= -1;
float4x4 ViewToObjectMatrix = mul(GetWorldToObjectMatrix(), GetViewToWorldMatrix());
o.viewRayOS.xyz = mul((float3x3)ViewToObjectMatrix, viewRay);
o.cameraPosOS.xyz = mul(ViewToObjectMatrix, float4(0,0,0,1)).xyz;
#if _ProjectionAngleDiscardEnable
float4 orientation = 0;
orientation.xyz = TransformObjectToWorldDir(float3(0,0,-1));
o.orientation = orientation;
#endif
return o;
}
half4 frag (v2f i) : SV_Target
{
// 在这里做除以z值,是为了防止在光栅化阶段差值时出现误差
i.viewRayOS.xyz /= i.viewRayOS.w;
// sample the texture
half2 screenPos = i.screenPos.xy / i.screenPos.w;
float depth = SampleSceneDepth(screenPos);
depth = LinearEyeDepth(depth, _ZBufferParams);
float3 decalSpaceScenePos;
decalSpaceScenePos = i.cameraPosOS.xyz + i.viewRayOS.xyz * depth;
// 这个仅对Unity自身的Cube生效,除非你的模型顶点范围也是在[-0.5,0.5]之间。
float2 decalSpaceUV = decalSpaceScenePos.xy + 0.5;
#if _ProjectionAngleDiscardEnable
float3 sNormal = SampleSceneNormals(screenPos);
clip(dot(normalize(i.orientation),normalize(sNormal)) - _ProjectionAngleDiscardThreshold);
#endif
// 这里判断是否超出模型的范围。因为使用的是Cube顶点范围也是在[-0.5,0.5],所以才可以这样判断
clip(0.5 - abs(decalSpaceScenePos));
float2 uv = decalSpaceUV.xy * _MainTex_ST.xy + _MainTex_ST.zw;
half4 col = tex2D(_MainTex, uv) * _Color;
return col;
}
ENDHLSL
}
}
}
具体分析
我们先暂时忽略_ProjectionAngleDiscardEnable
开启后的内容,这个我会在后面说明。
在顶点着色器中,除了简单的顶点变换外,这里还额外获取了屏幕坐标screenPos
,模型空间(Object Space)下的相机指向该顶点的方向viewRayOS
和相机在模型空间下的位置cameraPosOS
。screenPos
的获取不用多说,只是简单的调用函数罢了。viewRayOS
是先获取到观察空间下(View Space)的顶点的位置vertexPositionInput.positionVS
,因为Unity的相机空间是右手线(负轴指向屏幕),我们想要片段着色器中的正z射线,所以对其取反。这里我们还存储下了ViewRay.z
在其ViewRay.w
中,这是防止在光栅化时的插值而导致不正确。这里之所以可以将立方体顶点在观察空间下的位置作为视图方向,是因为在观察空间下相机的位置恒为(0,0,0)
。这也是为什么模型空间下的摄像机坐标是用(0,0,0,1)
(最后的1表示这个是点坐标,如果是0则表示向量。不过这个没用到)这个值变换而来。
在片元着色器中,我们先求得观察空间下的深度值。这里我们导入DeclareDepthTexture.hlsl
,然后简单的代它里面声明的函数就好了。这里我们要求一下前文所说的CPos即代码中的decalSpaceScenePos
。你会发现这个和我们的公式几乎一样,只是这些值除了深度depth
和viewRayOS.w
外都是在模型坐标系下。而depth
的值和viewRayOS.w
的值仍然是观察空间下的值。但实际上我们这里无需将其进行转换。如果我们要进行转换,那我们不难想象depth
的值和viewRayOS.w
的值要经过相同的变化才可以变为模型空间下的值。我们设这个变化值为changeVal,然后我们执行下面的操作:
i . v i e w R a y O S . x y z i . v i e w R a y O S . w ∗ c h a n g e V a l ∗ d e p t h ∗ c h a n g e V a l \frac{i.viewRayOS.xyz}{i.viewRayOS.w * changeVal}* depth * changeVal i.viewRayOS.w∗changeVali.viewRayOS.xyz∗depth∗changeVal
这里changeVal被消除掉了。所以我们并不需要进行这样的转换。我们将点位转换完以后要判断这个点位是否还在立方体中,在立方体中的点位,我们才进行渲染。因为Unity中默认的Cube在模型空间下的顶点范围在[-0.5,0.5]之间,所以代码中才是clip(0.5 - abs(decalSpaceScenePos));
。至于为什么直接使用decalSpaceScenePos.xy + 0.5
作为采样的UV,也是因为Unity中默认Cube的UV就是可以这样的去做的。
不知道你看代码的时候是否有这样的疑惑:为什么我们使用的是立方体的屏幕坐标系就能得到物体表面的深度。因为我们立方体的透明物体,一般而言透明物体不写入深度。而代码中我也没有做写入深度的操作。我们再看下面这张图片:
射线AD是在射线AC上的。因此D点和C点在转换为屏幕坐标系后,他们屏幕坐标系的x值和y值理应是一样的。又因为立方体不写入深度,因此我们使用屏幕坐标采集时得到的就是物体上顶点的深度值。这个是我个人在看代码时便有的疑惑,如果你也有,我希望这个解释能帮到你。
现在我们再来看看之前忽略的_ProjectionAngleDiscardEnable
。如果我们不开启_ProjectionAngleDiscardEnable
,我们会出现下面这样的效果:
不过这样的效果,其实也不能算是问题。这是要看整体项目的需求来做决定的。如果我们开启_ProjectionAngleDiscardEnable
则会变成下面这样:
这部分的实现,我是参考了Screen Space Decals in Warhammer 40,000: Space Marine,这篇文章的内容。实际上在我原本参考的项目(项目链接在后文)中也有类似的操作,但是我不能理解他的做法。所以我用了文章中的做法。文章的想法也很简单,在下图中
黄色的箭头是贴图立方体的朝向,黑色的箭头是被贴物体上表面的法线朝向。我们对这个两个方向归一化后做点乘,然后我们就可以得到他们间的sin
值。最后,我们再看看这个值是否大于我们设定的阈值。如果大于就剔除,否则就继续。
贴图立方体的朝向,其实就是模型空间下的(0,0,1)
。但是因为我觉得这是贴上去的,所以我这边应该取(0,0,-1)
。这里最难的地方是如何获取被贴物体上表面的法线朝向。其实这个和深度图是一个道理,我们要获取到相机法线图(_CameraNormalsTexture
是URP自带的相机法线图的变量名),然后我们使用屏幕坐标系采样就好了。但是如果你要贴的物体Shader中并没有实现类似下面的代码(这段代码是从Lit.shader中拷贝来的)
Pass
{
Name "DepthNormals"
Tags
{
"LightMode" = "DepthNormals"
}
// -------------------------------------
// Render State Commands
ZWrite On
Cull[_Cull]
HLSLPROGRAM
#pragma target 2.0
// -------------------------------------
// Shader Stages
#pragma vertex DepthNormalsVertex
#pragma fragment DepthNormalsFragment
// -------------------------------------
// Material Keywords
#pragma shader_feature_local _NORMALMAP
#pragma shader_feature_local _PARALLAXMAP
#pragma shader_feature_local _ _DETAIL_MULX2 _DETAIL_SCALED
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local_fragment _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
// -------------------------------------
// Unity defined keywords
#pragma multi_compile_fragment _ LOD_FADE_CROSSFADE
// -------------------------------------
// Universal Pipeline keywords
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrarRenderingLayers.hlsl"
//--------------------------------------
// GPU Instancing
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DOTS.hlsl"
// -------------------------------------
// Includes
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
ENDHLSL
}
那么你就不能得到正确的_CameraNormalsTexture
贴图。且你还需要开启DepthNormals
,否则你也得不到正确的信息。我上网查了一下,网上的方法是开启URP设置中自带的Render Feathers:Screen Space Ambient Occlusion
,然后将其设定为Depth Normals
(有关如何查找项目的URP设置,请看这篇文章:Unity查找URP设置的方法,如果你发现你设置中并灭有Screen Space Ambient Occlusion
。那你可以点击设置中的Add Render Feather
按钮去添加)。但是我其实根本不想要Screen Space Ambient Occlusion
的效果,而是只要项目能够有正确的_CameraNormalsTexture
贴图罢了。所以这里就要我们自己去写一个Render Feather去让URP去正确渲染_CameraNormalsTexture
。我查看了Screen Space Ambient Occlusion
发现其实关键代码就一段,如下:
ConfigureInput(ScriptableRenderPassInput.Normal);
所以我写了下面的代码:
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class StartDepthNormal : ScriptableRendererFeature
{
public class StartDepthPass : ScriptableRenderPass
{
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
}
public override void OnCameraCleanup(CommandBuffer cmd)
{
}
}
StartDepthPass m_ScriptablePass;
/// <inheritdoc/>
public override void Create()
{
m_ScriptablePass = new StartDepthPass();
m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(m_ScriptablePass);
m_ScriptablePass.ConfigureInput(ScriptableRenderPassInput.Normal);
}
}
上面大部分代码都是Unity自身生成的,我只是在AddRenderPasses
加上了m_ScriptablePass.ConfigureInput(ScriptableRenderPassInput.Normal);
,并在Create
函数中修改了m_ScriptablePass.renderPassEvent
为RenderPassEvent.AfterRenderingOpaques
。最后我们将我们自己创建的Render Feather添加至URP设置中,则我们也可以获取到正确的_CameraNormalsTexture
。
缺点
这个方法和我提供的Shader还是有些缺点的,下面是我整理的关于其的缺点。
-
我给的Shader中只考虑到了透视投影相机的情况。但是我参考的项目代码中是有对正交相机的情况的考虑,如果有需要大家可以去看一下下面的参考项目。
-
贴花效果会造成一定的性能压力。贴花的Shader无法使用合批操作。这自然不是因为Shader的Tags中,我故意加上了
"DisableBatching" = "True"
。而是我必须加上这个。如果进行合批,那么Shader中我们假设物体是Cube就不成立。因为进行合批后,网格也会被合批。合批后的网格就不再是Cube了,那么我们以Cube做的操作就是错的。这个是方法本身的问题。因此使用贴花会造成一定的性能压力。除此之外,我们需要深度图和相机法线图也是会造成一定的性能压力。 -
在Screen Space Decals in Warhammer 40,000: Space Marine文章中,作者建议我们贴花能尽量贴合我们的物体且尽量薄一些。这是因为我们其实还是一个立方体,如果我们的摄像机钻进我们立方体中,那显示的效果就有些奇怪了。虽然我们可以通过一些特别方法比如关闭深度测试、渲染双面来将效果变得不那么奇怪,但是这样又增加了性能的压力。
-
如果我们想使用
_ProjectionAngleDiscardEnable
的效果,我们必须要确保我们场景中能被贴花的物体都实现了DepthNormals
的Shader。这其实也挺麻烦的。
扩展————其他的贴花方法
如果你觉得这个贴花的方法很麻烦且不得你心。那么你可以查看以下的文章:Screen Space Decals in Warhammer 40,000: Space Marine,Unity Shader学习:贴花(Decal)。他们都有说明了其他的实现方法。
结语
本文的Shader实现是参考了这个项目UnityURP-Unlit ScreenSpaceDecal Shader(SRP batcher compatible)。但是有些我不理解的地方和我用不到的地方,我就丢弃掉了。我自己在使用的过程中觉得他的投影角度弃置(ProjectionAngleDiscard)的实现有些问题(必须强调一下,这个是我个人感觉),所以我换了一种实现方式。但是实际在我工程上,我并没有用到这个功能,所以我的写法也可能有问题。这部分如果各位有兴趣再去网上找资料去修改吧。如果后面我遇到这样的问题,我再来更新这篇文章吧。