前言

这几天学习了下Colin大神的屏幕空间贴花实现,感觉其中的算法实现和坐标转换让本笨比拍案叫绝,故记录分享一下。

大体思路

根据在任意坐标系中,已知一个物体A的坐标,以及另一个物体B相对于物体A的偏移量,即可得知物体B坐标的定理。我们可以将Camera坐标和从Camera到顶点的射线从相机空间转换到模型空间,然后利用从VS到PS的线性插值(其实是类似我们从深度图重建世界坐标时,构造四条从相机到屏幕面片四个顶点的射线,利用VS到PS的线性插值得到片元数目的射线原理,即可得知屏幕面片每一个片元对应的世界空间坐标),即可得知物体每一个片元对应的模型空间坐标,又因为我们使用了单位长度为1的Cube作为投射器,所以就可以直接用这个坐标当成UV去采样贴图(由于我们只是需要类似一个面片一样的贴花,所以需要裁剪掉除0~1范围外的所有UV),达成贴花的效果。

正文

基础设置

RenderQueue

1
"Queue" = "Transparent-499"

为了避免渲染顺序问题,把其渲染队列放到Transparent,并且在任何半透明物体之前渲染

ZWrite off

为了支持透明度混合,关闭深度写入

考虑:一个透明物体A深度小于当前贴花所用的投射器Cube,如果开启深度写入,就会导致A物体渲染错误(下图半透明砖块就是A,可以看到由于深度测试失败被透明的投射器Cube吞噬了)

v2-56f7a4b1d9476343cc485fe34322844c_1440w

正确结果应该是这样

v2-19ae9d334b158498dcf6854c5deb32f2_1440w

模型空间相机到顶点的射线算法示例

v2-c6afa30d7b23cbee7abb4d7c4f6b599d_1440w

(为了方便画图这里没有追求严谨性,变换后的射线所在坐标系是会与Cube的Local基向量一致的)如上图,相机为点A,以Cube的B顶点为目标发出射线,然后以B为起点,向相机A的Z轴做垂线,构成一个直角三角形:ABC△ABCABC\bigtriangleup ABCACAC即为BB的LinearEyeDepth值,当我们把射线和相机都转换到模型空间后,可以用前面的方法构造一个新的直角三角形:ABC\bigtriangleup AB''C''ABC\bigtriangleup AB''C''ACAC''即为BB''相机空间的深度值-z(因为相机空间是右手坐标系,所以z要取反),考虑到Model2WorldModel2World矩阵(从模型空间转世界空间的矩阵)的缩放系数,即有ABC    ABC\bigtriangleup ABC\;\sim\;\bigtriangleup AB''C'',根据相似三角形定理有ABAC  =  ABAC\frac{\displaystyle AB}{\displaystyle AC}\;=\;\frac{\displaystyle AB''}{\displaystyle AC''},所以我们顶点B在模型空间坐标就是    AB=ABACAC\;\;{AB}=\frac{\displaystyle{AB''}\cdot{AC}}{\displaystyle{AC''}},所以最终B点的模型空间坐标就是:

1
decalSpaceScenePos = i.cameraPosOS.xyz + i.viewRayOS.xyz * rcp(i.viewRayOS.w) * sceneDepthVS;

为什么不可以在VS中提前进行透视除法

首先这里的透视除法特制SSD中的手动模拟操作,而并非渲染流水线的透视除法。

因为经过透视相机下的投影矩阵变换后,Z分量的操作是非线性的(缩放+平移,会导致不满足线性变换定义之一——变换后原点不变,事实上这也正是为什么需要透视矫正插值(Perspective-Correct Interpolation)的原因),对其进行的任何操作也是非线性的,那么再经过线性插值(透视校正矫的也不是很正)后,得到的结果也是错误的。

PS:在更加通用的语境中,透视除法指vs之后,ps之前的vcc阶段的齐次除法,这一步是硬件来处理的,如果手动在vs中额外做了一次透视除法,数值肯定各种不对了。那么,为什么齐次除法要在硬件层做?这就牵扯到另一个概念:透视矫正插值,例如我们不能直接拿物体在ndc空间坐标为uv去采样,因为当物体不是平行于相机时,uv值是非线性的,需要通过透视矫正插值来处理,这一步也是硬件处理的,需要用到我们投影变换得到的齐次坐标的w分量。说到底是想要开发者们无需关心底层的细节,所以帮我们在硬件层做了齐次除法和透视矫正插值

更加细节的推理详见:SoftRenderer&RenderPipeline(从迷你光栅化软渲染器的实现看渲染流水线)

裁剪多余UV

v2-7701ccda1aec63f47670af6ed9f52a2e_1440w

由于我们只需要投射Cube与物体贴合的那部分UV,所以一些额外的UV需要裁剪掉,如上图,INSIDE区域是我们需要的,OUTSIDE部分是需要被裁剪的。出现这个问题的根本原因是深度值的不同导致最终计算出的模型空间坐标超出了原本-0.5~0.5的区间。

1
clip(0.5 - abs(decalSpaceScenePos));

过度拉伸裁剪支持

考虑一种情况,当一个投射器Cube被放在了一个拐角处,就会出现因为平面投影导致的UV超采样问题(根本原因是一个纹理坐标对应多个片元导致的极度拉伸,详情参见Fundamentals of Computer Graphic 纹理映射-平面投影),而这里我们没有办法很好的去处理这个问题(其实这里可以引出当前大世界PCG的经典峭壁问题),要么将这种情况进行裁剪,要么旋转Cube,让UV与切面有一定的倾斜角而不是直接垂直可以缓解

v2-c6f2f2fa54a1446b5c509019a4285999_1440w

1
2
3
4
5
6
7
8
9
float shouldClip = 0;
#if _ProjectionAngleDiscardEnable

float3 decalSpaceHardNormal = normalize(cross(ddx(decalSpaceScenePos), ddy(decalSpaceScenePos)));
shouldClip = decalSpaceHardNormal.z < _ProjectionAngleDiscardThreshold ? 0 : 1;

#endif

clip(0.5 - abs(decalSpaceScenePos) - shouldClip);

原理就是利用偏导数求出X,Y轴斜率然后叉乘得到法线信息,再通过法线信息与我们设定的临界值比较,如果太离谱就直接裁剪

v2-610b1460a5920abedcac226bf829b232_1440w

对于偏导数的几何意义,我找出了我的早年作品:(这是人写的字?)

v2-2870c35ff355531f231cc45a3cb6be5f_1440w

正交相机支持

这个SSD还支持了正交相机,主要对于正交相机的深度值处理,我们需要手动对它进行线性插值,最后是重建世界坐标的时候我想了很久也没有明白为什么要对相机的世界坐标*2,如果有知道的大佬请不吝赐教。

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
#if _SupportOrthographicCamera
// static uniform branch depends on unity_OrthoParams.w, w = 1.0 if camera is ortho, 0.0 if perspective
// (should we use UNITY_BRANCH here?) decided NO because https://forum.unity.com/threads/correct-use-of-unity_branch.476804/
if(unity_OrthoParams.w)
{
// if orthographic camera, _CameraDepthTexture store scene depth linearly within [0,1]
// if platform use reverse depth, make sure to 1-depth also
// https://docs.unity3d.com/Manual/SL-PlatformDifferences.html
#if defined(UNITY_REVERSED_Z)
sceneRawDepth = 1-sceneRawDepth;
#endif
// simply lerp(near,far, [0,1] linear depth) to get view space depth
float sceneDepthVS = lerp(_ProjectionParams.y, _ProjectionParams.z, sceneRawDepth);
//***Used a few lines from Asset: Lux URP Essentials by forst***
//----------------------------------------------------------------------------
// reconstruct posVSOrtho
float2 viewRayEndPosVS_xy = float2(unity_OrthoParams.xy * (i.screenPos.xy * 2 - 1));
float3 posVSOrtho = float3(-viewRayEndPosVS_xy, -sceneDepthVS);
// convert posVSOrtho to posWS
float3 posWS = mul(unity_CameraToWorld, float4(posVSOrtho,1)).xyz;
posWS -= _WorldSpaceCameraPos * 2; // Don't understand this, Why * 2?
posWS *= -1;
//----------------------------------------------------------------------------
// transform world to object space(decal space)
decalSpaceScenePos = mul(UNITY_MATRIX_I_M, float4(posWS, 1)).xyz;
}

Stencil Test

我们会有这样的需求,人物走在贴花上时不会被贴花,而是直接踩在上面(下图来自:http://rainyeve.com/wordpress/?p=663)

v2-92d2d4b7afed73cb387d7a723666a8c3_1440w

所以就需要进行模板测试

1
2
3
4
5
Stencil
{
Ref[_StencilRef]
Comp[_StencilComp]
}

这里设置引用值为1,对比操作为NotEqual,意思是每个片元拿当前模板缓冲区值(默认值为0)与1对比,如果不等于1,即为测试通过,否则将丢弃片元

v2-d63331ebfa3dbd506df0e105a49fb79f_1440w

那么我们就可以为我们的人物Shader加上模板测试

1
2
3
4
5
6
Stencil
{
Ref 1
Comp NotEqual
Pass Replace
}

即可达成效果

v2-18ed9fe55018190b54399f0c723c5302_1440w

总结

从Colin大神的Shader中学到许多,理解到自己平时写的Shader和工业级Shader的差距(需要多方面考虑健壮性),也是头一次知道Shader也可以写的这么优雅,希望自己再接再厉,有朝一日自己也能跟着Siggraph,GDC复刻出想要的效果。

参考

游戏中的Decal(贴花)

UnityURPUnlitScreenSpaceDecalShader

Screen Space Decals in Warhammer 40,000: Space Marine

Fundamentals of Computer Graphic 纹理映射

大世界PCG的经典峭壁问题

SoftRenderer&RenderPipeline(从迷你光栅化软渲染器的实现看渲染流水线)