
本章是紧接着上次的模板测试做的一个效果,算是模板测试又一个常见的应用吧(即防止半透明物体反复渲染多次)。
平面投影算是业内常用的一种优化投影性能的手段。因为常规的实时投影需要去算一张阴影图,性能开销相对来说比较大,所以在一些特殊场合(例如场景的地面基本都是平的,并且投影不需要做一些像是边缘模糊这类的操作),改用平面投影是个不错的选择。(实际项目中可能还需要单独做一个低模来做平面投影)
这里是在常见的针对地面的平面投影基础上,将投影面推广到了任意角度平面的情况(虽然本质上也没什么技术含量,就是个平面方程和直线方程联立求解的问题,学过高数的应该都晓得)。除此之外,解决一些在实现过程中碰到的诸如投影挡在模型前,在平面后方的投影裁剪等小问题。

平面投影的基本思路是,在顶点shader中,计算以当前顶点为原点,光向量的反方向为方向的射线与某一给定平面的交点(即下图中的红点),然后把这个计算得来的交点的坐标转换为裁剪坐标进行后续计算。

1-1
这个过程,实际上就是把模型沿着光照方向拍扁在某个平面上。查看网格就能发现,投影实际上是个被拍扁的模型。

1-2

上面这段话可以用数学抽象成一个平面和直线求交点的问题。以防有没学过平面or直线方程的小伙伴,下面给出两个方程的一般式。
平面
其中,向量(a,b,c)是这个面的法向量。
直线
其中,点(px,py,pz)是这条直线上的某个点(比如在shader中就可以把顶点坐标带进去),向量(lx,ly,lz)是直线的方向向量。
本案例的问题便可以通过联立上述两个方程求解来解决,结果如下。
其中,pin是直线上一个已知点,n是平面法向量,l是直线方向向量,于是就可以写下面这个方法来计算交点。
half3 GetCrossPos(half3 posIN, half3 lDir)
{
half t = -(_Plane.w + dot(posIN, _Plane.xyz)) / dot(lDir, _Plane.xyz);
return t * lDir + posIN;
}

shader
下面的shader包含两个pass,第一个pass写了个简单的lambert光照来看效果,第二个是平面阴影本体。(URP无法像内置管线一样,有多个Pass直接写就行。示例的在Pass中加Tag配置LightMode的写法是在URP中实现双Pass的方法之一,若是更多的Pass一般需要renderfeature控制)
//URP
Shader "PlaneShadow"
{
Properties
{
_Color ("投影颜色", color) = (0, 0, 0, 0.5)
_Plane ("平面方程", vector) = (0, 1, 0, 0)
_LDir ("光照方向", vector) = (0, 1, 0, 0)
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
//Lambert
Pass
{
Tags
{
"LightMode" = "UniversalForward"
}
Cull back
ZWrite on
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half3 _LDir;
CBUFFER_END
struct a2v
{
half4 posOS : POSITION;
half3 nDirOS : NORMAL;
};
struct v2f
{
half4 posCS : SV_POSITION;
half3 nDirWS : TEXCOORD0;
};
v2f vert (a2v i)
{
v2f o;
//坐标
o.posCS = TransformObjectToHClip(i.posOS.xyz);
//向量
o.nDirWS = TransformObjectToWorldNormal(i.nDirOS);
return o;
}
half4 frag (v2f i) : SV_Target
{
half3 nDirWS = normalize(i.nDirWS);
half3 lDirWS = normalize(_LDir);
half lambert = saturate(dot(nDirWS, lDirWS));
return half4(lambert.rrr, 0.0);
}
ENDHLSL
}
//投影
Pass
{
Tags
{
"LightMode" = "SRPDefaultUnlit"
}
Blend SrcAlpha OneMinusSrcAlpha
Cull back
ZWrite on
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half4 _Color;
half4 _Plane;
half3 _LDir;
CBUFFER_END
//任意平面求交点
half3 GetCrossPos(half3 posIN, half3 lDir)
{
half t = -(_Plane.w + dot(posIN, _Plane.xyz)) / dot(lDir, _Plane.xyz);
return t * lDir + posIN;
}
struct a2v
{
half4 posOS : POSITION;
};
struct v2f
{
half4 posCS : SV_POSITION;
};
v2f vert (a2v i)
{
v2f o;
o.posCS = TransformWorldToHClip(GetCrossPos(TransformObjectToWorld(i.posOS), _LDir));
return o;
}
half4 frag (v2f i) : SV_Target
{
return _Color;
}
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
} C#
这是系列文章中第一次碰到用脚本控制shader的情况。以下代码的作用是,在光照或平面发生改变时,将光向量和经过计算的平面方程系数传递给指定材质。其中的material.SetXXX(shader参数名, 传入的变量)是常见的用脚本动态控制材质参数的方法。
using UnityEngine;
[ExecuteAlways]
public class PlaneShadowCS : MonoBehaviour
{
public Material mat;//材质
public Transform planeTf;//平面
public Light light;//光源
void Update()
{
if (mat && planeTf && light)
{
//光
if (light.transform.hasChanged)
{
mat.SetVector("_LDir", -light.transform.forward);
light.transform.hasChanged = false;
}
//平面
if (planeTf.hasChanged)
{
Vector3 planeNormal = planeTf.up;
Vector4 planeParams = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, -Vector3.Dot(planeNormal, planeTf.position));
mat.SetVector("_Plane", planeParams);
planeTf.hasChanged = false;
}
}
}
} 效果
用一个粉色妖精小姐的模型来做示例如下。

3-1
虽然投影的形状和方向都是对的,也是贴着平面走的,但很明显,其他地方非常不对劲,影子像被炸碎了一样。这个现象有个专门的名词——ZFighting,其实就是当两个面靠的太近时,因为深度值精度的问题导致的遮挡剔除冲突,也就是下面的平面有一些地方挡住了投影。
因为在上面的脚本中,我们直接将下面那个平面模型的方程系数算出来就用了,也就是shader中算出来的点也是完全贴在平面上,所以导致了这个错误。下面就先来解决这个问题。

这里采用的解决方法是,在脚本中不直接计算平面方程的系数,而是计算原平面沿法线方向平移一个微小增量后的平面方程系数,即在下面这个地方加上这么一句。
Vector4 planeParams = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, -Vector3.Dot(planeNormal, planeTf.position + 0.001f * planeNormal/*偏移增量*/)); 如此便可保证shader计算的平面和原平面不是完全重合,且在其前方。

4-1
很显然,问题还没完。
这种投影黑一块白一块的现象是因为,在这个shader中,投影的渲染是带透明度的,而拍扁的模型难免会发生多个面重合的情况。在这种情况下,半透明的面重复渲染了多次,叠的层数越多颜色越深。为了解决这个问题就需要用到上次的模板测试。

为解决上面的问题,投影部分的渲染逻辑应当是:如果这个像素之前渲染过投影,则跳过;否则渲染投影。这种分支逻辑正是模板测试所擅长的。
只需要在投影的Pass中插入如下模板测试的配置即可实现上面这种渲染分支。
Stencil
{
Ref 1
Comp NotEqual
Pass Replace
} 然而问题还没有结束。当模型与平面有交叉时,异常再次出现。

5-1
显然,正确的效果应当不包括腿部的投影,但这个shader在原理上是将整个模型拍平在平面上,而像素shader中又只是返回了个颜色,所以效果是上图左侧。
我们需要在像素shader中做一点裁剪处理,判断原模型是在平面前还是后,并把在模型后的像素都干掉。

这里采用的方法是,通过投影向量与光向量的点积正负来判断原模型是否在平面后。

6-1
途中的黄色箭头是光向量,蓝色是投影向量(即被拍平后的模型的世界坐标 - 原模型的世界坐标),红色是产生的投影。可以发现,当投影向量和光向量同向时,意味着原模型的坐标在平面下方,否则在平面上方。同向即点乘为正,反之为负。
我们可以在顶点shader中提前计算投影向量,然后在像素shader中做点乘判断以及透明度归0。修改后的完整代码及效果如下。
//URP
Shader "PlaneShadow"
{
Properties
{
_Color ("投影颜色", color) = (0, 0, 0, 0.5)
_Plane ("平面方程", vector) = (0, 1, 0, 0)
_LDir ("光照方向", vector) = (0, 1, 0, 0)
}
SubShader
{
Tags
{
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Transparent"
"Queue" = "Transparent"
}
//Lambert
Pass
{
Tags
{
"LightMode" = "UniversalForward"
}
Cull back
ZWrite on
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half3 _LDir;
CBUFFER_END
struct a2v
{
half4 posOS : POSITION;
half3 nDirOS : NORMAL;
};
struct v2f
{
half4 posCS : SV_POSITION;
half3 nDirWS : TEXCOORD0;
};
v2f vert (a2v i)
{
v2f o;
//坐标
o.posCS = TransformObjectToHClip(i.posOS.xyz);
//向量
o.nDirWS = TransformObjectToWorldNormal(i.nDirOS);
return o;
}
half4 frag (v2f i) : SV_Target
{
half3 nDirWS = normalize(i.nDirWS);
half3 lDirWS = normalize(_LDir);
half lambert = saturate(dot(nDirWS, lDirWS));
return half4(lambert.rrr, 0.0);
}
ENDHLSL
}
//投影
Pass
{
Tags
{
"LightMode" = "SRPDefaultUnlit"
}
Stencil
{
Ref 1
Comp NotEqual
Pass Replace
}
Blend SrcAlpha OneMinusSrcAlpha
Cull back
ZWrite on
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
half4 _Color;
half4 _Plane;
half3 _LDir;
CBUFFER_END
//任意平面求交点
half3 GetCrossPos(half3 posIN, half3 lDir)
{
half t = -(_Plane.w + dot(posIN, _Plane.xyz)) / dot(lDir, _Plane.xyz);
return t * lDir + posIN;
}
struct a2v
{
half4 posOS : POSITION;
};
struct v2f
{
half4 posCS : SV_POSITION;
half3 shadowDir : TEXCOORD0;
};
v2f vert (a2v i)
{
v2f o;
half3 posWS = TransformObjectToWorld(i.posOS.xyz);
half3 posWS_Shadow = GetCrossPos(posWS, _LDir);
o.posCS = TransformWorldToHClip(posWS_Shadow);
o.shadowDir = posWS_Shadow - posWS;
return o;
}
half4 frag (v2f i) : SV_Target
{
return _Color * step(dot(i.shadowDir, _LDir), 0);
}
ENDHLSL
}
}
FallBack "Hidden/Universal Render Pipeline/FallbackError"
}

6-2

以上面为基础,其实还可以往里塞一些别的效果,比如根据投影和原模型间的距离来计算的投影淡化,或者将示例中的平行光算法改为点光源等。因为并不是什么太复杂的改动(犯懒),本文也就到此为止不说了_(:з」∠)_