关于任意角度平面投影的实现
伊底_1D
2022年07月07日 19:27
收录于文集
共16篇

前言

        本章是紧接着上次的模板测试​做的一个效果,算是模板测试又一个常见的应用吧(即防止半透明物体反复渲染多次)。

        平面投影算是业内常用的一种优化投影性能的手段。因为常规的实时投影需要去算一张阴影图,性能开销相对来说比较大,所以在一些特殊场合(例如场景的地面基本都是平的,并且投影不需要做一些像是边缘模糊这类的操作),改用平面投影是个不错的选择。(实际项目中可能还需要单独做一个低模来做平面投影)

        这里是在常见的针对地面的平面投影基础上,将投影面推广到了任意角度平面的情况(虽然本质上也没什么技术含量,就是个平面方程和直线方程联立求解的问题,学过高数的应该都晓得)。除此之外,解决一些在实现过程中碰到的诸如投影挡在模型前,在平面后方的投影裁剪等小问题。

一、压扁你的模型

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

1-1

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

1-2

二、数学原理

        上面这段话可以用数学抽象成一个平面和直线求交点的问题。以防有没学过平面or直线方程的小伙伴,下面给出两个方程的一般式。

平面

        ax%2Bby%2Bcz%2Bd%3D0

        其中,向量(a,b,c)是这个面的法向量。

直线

        %5Cfrac%7Bx-px%7D%7Blx%7D%20%3D%5Cfrac%7By-py%7D%7Bly%7D%20%3D%5Cfrac%7Bz-pz%7D%7Blz%7D%20%0A

        其中,点(px,py,pz)是这条直线上的某个点(比如在shader中就可以把顶点坐标带进去),向量(lx,ly,lz)是直线的方向向量。

        本案例的问题便可以通过联立上述两个方程求解来解决,结果如下。

        p_%7Bout%7D%3Dt%C2%B7%5Cvec%7Bl%7D%2Bp%20_%7Bin%7D

t%3D-%5Cfrac%7Bd%2B%5Cvec%7Bn%7D%C2%B7p_%7Bin%7D%20%7D%7B%5Cvec%7Bn%7D%C2%B7%5Cvec%7Bl%7D%7D%20

        其中,pin是直线上一个已知点,n是平面法向量,l是直线方向向量,于是就可以写下面这个方法来计算交点。

代码块
text/x-csrc
自动换行
复制代码
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控制)

代码块
text/x-csrc
自动换行
复制代码
//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参数名, 传入的变量)是常见的用脚本动态控制材质参数的方法。

代码块
text/x-csrc
自动换行
复制代码
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中算出来的点也是完全贴在平面上,所以导致了这个错误。下面就先来解决这个问题。

四、消除ZFighting

        这里采用的解决方法是,在脚本中不直接计算平面方程的系数,而是计算原平面沿法线方向平移一个微小增量后的平面方程系数,即在下面这个地方加上这么一句。

代码块
text/x-csrc
自动换行
复制代码
Vector4 planeParams = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, -Vector3.Dot(planeNormal, planeTf.position + 0.001f * planeNormal/*偏移增量*/));
复制成功

        如此便可保证shader计算的平面和原平面不是完全重合,且在其前方。

4-1

        很显然,问题还没完。

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

五、保证投影只渲染一层

        为解决上面的问题,投影部分的渲染逻辑应当是:如果这个像素之前渲染过投影,则跳过;否则渲染投影。这种分支逻辑正是模板测试所擅长的。

        只需要在投影的Pass中插入如下模板测试的配置即可实现上面这种渲染分支。

代码块
text/x-csrc
自动换行
复制代码
Stencil
{
	Ref 1
	Comp NotEqual
	Pass Replace 
}
复制成功

        然而问题还没有结束。当模型与平面有交叉时,异常再次出现。

5-1

        显然,正确的效果应当不包括腿部的投影,但这个shader在原理上是将整个模型拍平在平面上,而像素shader中又只是返回了个颜色,所以效果是上图左侧。

        我们需要在像素shader中做一点裁剪处理,判断原模型是在平面前还是后,并把在模型后的像素都干掉。

六、投影裁剪

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

6-1

        途中的黄色箭头是光向量,蓝色是投影向量(即被拍平后的模型的世界坐标 - 原模型的世界坐标),红色是产生的投影。可以发现,当投影向量和光向量同向时,意味着原模型的坐标在平面下方,否则在平面上方。同向即点乘为正,反之为负。

        我们可以在顶点shader中提前计算投影向量,然后在像素shader中做点乘判断以及透明度归0。修改后的完整代码及效果如下。

代码块
text/x-csrc
自动换行
复制代码
//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

七、更进一步?

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