美文网首页
从零开始在Unity中写一个可分离的次表面散射(Separabl

从零开始在Unity中写一个可分离的次表面散射(Separabl

作者: 上善若水_2019 | 来源:发表于2019-12-18 15:38 被阅读0次

    上一次博客中实现了一个简单的PBR,既然提到了PBR,又怎么能不提一下3S(Subsurface Scattering,次表面散射)。在Disney最初的论文里,3S只是PBR材质中的一个变量,名叫subsurface,通过这个来控制次表面散射的程度。然而到了实时渲染领域,特别是游戏领域,这个东西被单独提了出来,相应发展出了很多种技术来实现它。理论这里我也不讲了,《GPU Gems 3》:真实感皮肤渲染技术总结已经讲得非常棒了。

    从实现上来说,有基于纹理空间的模糊,有基于屏幕空间的模糊,有改进改进半透明阴影贴图(Translucent Shadow Maps,TSMs),有预积分的皮肤着色(Pre-Integrated Skin Shading),有结合延迟渲染技术(Deferred Single Scattering)的,还有最新的是路径追踪次表面散射(Path-Traced Subsurface Scattering),这种区别于传统的光栅图形学,用了光线追踪技术,是基于Ray Marching的解决方案。

    本文并非要实现以上技术,而是实现由动视暴雪于2013年首先应用的技术,2年后他们把这种技术写成论文,称作Separable Subsurface Scattering,可以叫它4S技术。它也是一种基于屏幕空间模糊的技术,不过相比于之前的屏幕空间技术,它大大降低了消耗。原来的技术需要6次高斯模糊,而一次模糊需要x,y方向都做一个pass,6次就要12个pass来满足需要。现在4S技术只需要2个pass来做模糊,所以成为了现在游戏业界的主流技术,Unreal也对此进行了集成。

    另外,我参考(抄袭?)了separable-sssUnity_ScreenSpaceTechStackseparable-sss-unity
    以及Unity-Human-Skin-Shader-PC这四个项目,才最终实现了4S,不过其中4S的核心技术我也不是很明白,属于别人怎么做我也怎么做的阶段,如果以后搞懂了,可以再回来解释。

    首先,既然4S是一种基于屏幕空间的技术,那么到Unity里就是后处理效果了。本质上这种技术就是屏幕空间模糊,那么我们先创建一个后处理文件挂在Camera上

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.Rendering;
    
    [ExecuteInEditMode]
    [RequireComponent(typeof(Camera))]
    public class SubsurfaceScatterPostProcessing : MonoBehaviour
    {
        [Range(2,50)]
        public int nSamples = 25;
        [Range(0,3)]
        public float scaler = 0.1f;
        public Color strength;
        public Color falloff;
        Camera mCam;
        CommandBuffer buffer;
        Material mMat;
    
        private static int SceneID = Shader.PropertyToID("_SceneID");//用一个数代表现当前RT,_SceneID没有用在任何地方,这样返回的数不会和其他冲突
        private static int SSSScaler = Shader.PropertyToID("_SSSScaler");
        private static int SSSKernel = Shader.PropertyToID("_Kernel");
        private static int SSSSamples = Shader.PropertyToID("_Samples");
    
        private void OnEnable() {
            mCam = GetComponent<Camera>();
            mCam.depthTextureMode |= DepthTextureMode.Depth;
            mMat = new Material(Shader.Find("Unlit/SSS"));
            
            buffer = new CommandBuffer();
            buffer.name = "Separable Subsurface Scatter";
            mCam.clearStencilAfterLightingPass = true;
            mCam.AddCommandBuffer(CameraEvent.AfterForwardOpaque,buffer);
        }
    
        private void OnPreRender() {
            Vector3 normalizedStrength = Vector3.Normalize(new Vector3(strength.r,strength.g,strength.b));
            Vector3 normalizedFallOff = Vector3.Normalize(new Vector3(falloff.r,falloff.g,falloff.b));
            List<Vector4> kernel = KernelCalculator.CalculateKernel(nSamples,normalizedStrength,normalizedFallOff);
            mMat.SetInt(SSSSamples,nSamples);
            mMat.SetVectorArray(SSSKernel,kernel);
            mMat.SetFloat(SSSScaler,scaler);
    
            buffer.Clear();
            buffer.GetTemporaryRT(SceneID,mCam.pixelWidth,mCam.pixelHeight,0,FilterMode.Trilinear,RenderTextureFormat.DefaultHDR);
            buffer.BlitStencil(BuiltinRenderTextureType.CameraTarget,SceneID,BuiltinRenderTextureType.CameraTarget,mMat,0);
            buffer.BlitSRT(SceneID,BuiltinRenderTextureType.CameraTarget,mMat,1);
        }
    
    
        private void OnDisable() {
            buffer.ReleaseTemporaryRT(SceneID);
            mCam.RemoveCommandBuffer(CameraEvent.AfterForwardOpaque,buffer);
            buffer.Release();
        }
    }
    
    

    里面最重要的就是KernelCalculator.CalculateKernel这个方法,决定了这个模糊到底该怎么模糊,其余都是些Command Buffer的应用,不过有两个方法BlitStencilBlitSRT并不是Command Buffer里提供的,是用了C#的一个特性Extension Methods实现的,是这样实现的

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.Rendering;
    
    public static class GraphicsHelper
    {
        private static Mesh mMesh;
    
        private static Mesh mesh{
            get{
                if (mMesh != null){
                    return mMesh;
                }
                mMesh = new Mesh();
                mMesh.vertices = new Vector3[]{
                                  new Vector3(-1,-1,0),
                                  new Vector3(-1,1,0),
                                  new Vector3(1,1,0),
                                  new Vector3(1,-1,0)
                };
                mMesh.uv = new Vector2[]{
                            new Vector2(0,1),
                            new Vector2(0,0),
                            new Vector2(1,0),
                            new Vector2(1,1)
                };
                mMesh.SetIndices(new int[]{0,1,2,3},MeshTopology.Quads,0);
                return mMesh;
            }
        }
    
        public static void BlitSRT(this CommandBuffer buffer,RenderTargetIdentifier source, RenderTargetIdentifier destination,Material material, int pass){
            buffer.SetGlobalTexture(ShaderID._MainTex,source);
            buffer.SetRenderTarget(destination);
            buffer.DrawMesh(mesh,Matrix4x4.identity,material,0,pass);
        }
    
        public static void BlitStencil(this CommandBuffer buffer,RenderTargetIdentifier colorSrc, RenderTargetIdentifier colorBuffer, RenderTargetIdentifier depthStencilBuffer,Material material,int pass){
            buffer.SetGlobalTexture(ShaderID._MainTex,colorSrc);
            buffer.SetRenderTarget(colorBuffer,depthStencilBuffer);
            buffer.DrawMesh(mesh,Matrix4x4.identity,material,0,pass);
        }
    
    }
    
    

    为什么要这样写呢?Unity-Human-Skin-Shader-PC项目里为了兼容延迟渲染写了不少这样的方法,我把这两个对项目有用的方法拿了出来(我主要测试前向渲染,延迟渲染不怎么考虑)。
    从代码来看,就是依靠KernelCalculator.CalculateKernel这个方法算出一个Kernel Array传给shader(我叫它Unlit/SSS,用来做x,y方向的2次模糊),利用这个shader产生的材质把屏幕原来的图像给模糊一下,从实现上来说和普通的模糊特效实现过程差不了多少。

    然后来看KernelCalculator.CalculateKernel这个方法,实现如下

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class KernelCalculator
    {
        /**
         * We use a falloff to modulate the shape of the profile. Big falloffs
         * spreads the shape making it wider, while small falloffs make it
         * narrower.
         */
        private static Vector3 Gaussian(float variance, float r, Vector3 falloff){
            Vector3 g = Vector3.zero;
            for (int i=0;i<3;i++){
                float rr = r / (0.001f + falloff[i]);
                g[i] = Mathf.Exp((-(rr*rr)) / (2.0f * variance)) / (2.0f * Mathf.PI * variance);
            }
            return g;
        }
    
        /**
         * We used the red channel of the original skin profile defined in
         * [d'Eon07] for all three channels. We noticed it can be used for green
         * and blue channels (scaled using the falloff parameter) without
         * introducing noticeable differences and allowing for total control over
         * the profile. For example, it allows to create blue SSS gradients, which
         * could be useful in case of rendering blue creatures.
         */
        private static Vector3 Profile(float r, Vector3 falloff){
            return 0.100f * Gaussian(0.0484f, r, falloff) +
                   0.118f * Gaussian(0.187f, r, falloff) +
                   0.113f * Gaussian(0.567f, r, falloff) +
                   0.358f * Gaussian(1.99f, r, falloff) +
                   0.078f * Gaussian(7.41f, r, falloff);
        }
    
        public static List<Vector4> CalculateKernel(int nSamples, Vector3 strength, Vector3 falloff){
            List<Vector4> kernel = new List<Vector4>();
    
            float RANGE = nSamples > 20 ? 3.0f : 2.0f;
            float EXPONENT = 2.0f;
    
            //calculate the offsets
            float step = 2.0f * RANGE / (nSamples - 1);
            for (int i=0;i<nSamples;i++){
                float o = -RANGE + i * step;
                float sign = o < 0.0f ? -1.0f : 1.0f;
                float w = RANGE * sign * Mathf.Abs(Mathf.Pow(o,EXPONENT)) / Mathf.Pow(RANGE, EXPONENT);
                kernel.Add(new Vector4(0,0,0,w));
            }
    
            //calculate the weights
            for (int i=0;i<nSamples;i++){
                float w0 = i > 0 ? Mathf.Abs(kernel[i].w - kernel[i-1].w) : 0.0f;
                float w1 = i < nSamples - 1 ? Mathf.Abs(kernel[i].w - kernel[i+1].w) : 0.0f;
                float area = (w0 + w1) / 2.0f;
                Vector3 temp = area * Profile(kernel[i].w,falloff);
                kernel[i] = new Vector4(temp.x,temp.y,temp.z,kernel[i].w);
            }
    
            //We want the offset 0.0 come first
            Vector4 t = kernel[nSamples / 2];
            for (int i=nSamples/2;i>0;i--){
                kernel[i] = kernel[i-1];
            }
            kernel[0] = t;
    
            //calculate the sum of the weights, we will need to normalize them below
            Vector4 sum = Vector4.zero;
            for (int i=0;i<nSamples;i++){
                sum += kernel[i];
            }
    
            //normalize the weight
            for(int i=0;i<nSamples;i++){
                Vector4 v = kernel[i];
                v.x /= sum.x;
                v.y /= sum.y;
                v.z /= sum.z;
                kernel[i] = v; 
            }
    
            // Tweak them using the desired strength. The first one is:
            //      lerp(1.0, kernel[0].rgb, strength)
            Vector4 v0 = kernel[0];
            v0.x = (1.0f - strength.x) * 1.0f + strength.x * v0.x;
            v0.y = (1.0f - strength.y) * 1.0f + strength.y * v0.y;
            v0.z = (1.0f - strength.z) * 1.0f + strength.z * v0.z;
            kernel[0] = v0;
    
            // The others:
            //     lerp(0.0, kernel[0].rgb, strength)
            for (int i=1;i<nSamples;i++){
                Vector4 v = kernel[i];
                v.x *= strength.x;
                v.y *= strength.y;
                v.z *= strength.z;
                kernel[i] = v;
            }
    
            return kernel;
        }
    }
    
    

    这个就是把separable-sss项目的C++代码翻译成了C#代码,并且我把原项目里的注释也copy过来了,如果哪天看懂了4S的核心算法,这个我也能懂了>_<。

    最后来看用来模糊的那个shader(Unlit/SSS),这个shader分成两部分,一部分公用的叫SSSCommon.cginc,另一部分就是SSS了。

    #include "UnityCG.cginc"
    
    #define DistanceToProjectionWindow 5.671281819617709   // 1.0 / tan(0.5 * radians(20))
    #define DPTimes300 1701.384545885313                     //DistanceToProjectionWindow * 300
    
    struct appdata
    {
        float4 vertex : POSITION;
        float2 uv : TEXCOORD0;
    };
    
    struct v2f
    {
        float2 uv : TEXCOORD0;
        float4 vertex : SV_POSITION;
    };
    
    sampler2D _CameraDepthTexture;
    float4 _CameraDepthTexture_TexelSize;
    sampler2D _MainTex;
    float4 _MainTex_ST;
    float _SSSScaler;
    float4 _Kernel[100];
    int _Samples;
    
    v2f vert (appdata v)
    {
        v2f o;
        o.vertex = v.vertex;
        o.uv = TRANSFORM_TEX(v.uv, _MainTex);
        return o;
    }
    
    float4 SSS(float4 sceneColor, float2 uv, float2 sssIntensity){
        float sceneDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv));
        float blurLength = DistanceToProjectionWindow / sceneDepth;
        float2 uvOffset = sssIntensity * blurLength;
        float4 blurSceneColor = sceneColor;
        blurSceneColor.rgb *= _Kernel[0].rgb;
    
        [loop]
        for(int i=1;i<_Samples;i++){
            float2 sssUV = uv + _Kernel[i].a * uvOffset;
            float4 sssSceneColor = tex2D(_MainTex, sssUV);
            float sssDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sssUV)).r;
            float sssScale = saturate(DPTimes300 * sssIntensity * abs(sceneDepth - sssDepth));
            sssSceneColor.rgb = lerp(sssSceneColor.rgb, sceneColor.rgb,sssScale);
            blurSceneColor.rgb += _Kernel[i].rgb * sssSceneColor.rgb;
        }
        return blurSceneColor;
    }
    

    SSSCommon.cginc在通过深度以及传进来的Kernel Array做一些模糊的计算,SSS就是具体两个方向的模糊了。不过这里我并不明白DistanceToProjectionWindowDPTimes300的意义,有没有知道的同学能解释一下?

    Shader "Unlit/SSS"
    {
        CGINCLUDE
            #include "SSSCommon.cginc"
        ENDCG
    
        SubShader
        {
            Tags { "RenderType"="Opaque" }
            LOD 100
    
            ZTest Always
            ZWrite Off
            Cull Off
            Stencil{
                Ref 5
                Comp Equal
                Pass Keep
            }
    
            Pass
            {
                Name "XBlur"
                CGPROGRAM
                #pragma vertex vert
                #pragma fragment frag
    
                fixed4 frag (v2f i) : SV_Target
                {
                    // sample the texture
                    float4 col = tex2D(_MainTex, i.uv);
                    float sssIntensity = _SSSScaler * _CameraDepthTexture_TexelSize.x;
                    float3 xBlur = SSS(col, i.uv, float2(sssIntensity,0)).rgb;
    
                    return float4(xBlur,col.a);
                }
                ENDCG
            }
    
            Pass
            {
                Name "YBlur"
                CGPROGRAM
                #pragma vertex vert
                #pragma fragment frag
    
                fixed4 frag (v2f i) : SV_Target
                {
                    // sample the texture
                    float4 col = tex2D(_MainTex, i.uv);
                    float sssIntensity = _SSSScaler * _CameraDepthTexture_TexelSize.y;
                    float3 yBlur = SSS(col, i.uv, float2(0,sssIntensity)).rgb;
    
                    return float4(yBlur,col.a);
                }
                ENDCG
            }
        }
    }
    
    

    注意这里我用了stencil test,所以在需要被4S技术所模糊的那个(或几个)对象的shader(我用了自己上次写的简陋版PBR着色器)里需要加入这样一段来启用模糊

    Stencil{
                Ref 5
                Comp Always
                Pass Replace
            }
    

    并且,我用的Unity2019.3版本里加了上一面那段stencil依然不能开启模糊,必须要在shader最后加上Fallback才能起效,难道stencil的使用方法改变了?

    最后放上效果图

    PBR效果,没开4S模糊

    感觉这个已经很不错了,贴图模型做的非常好啊。下面是开了4S效果的图


    4S开启

    模糊了一下看起来暗了点,我们再来把模糊调大点看看


    更多的4S

    感觉从一个中年大叔变年轻了,满脸的胶原蛋白。。。

    这里有个坑。。。如果我把相机的MSAA属性设为Use Graphics Settings,那么渲染结果将是全黑(至少Windows平台是这样的),我还没整明白为什么=_=!

    项目地址

    参考
    Unity_SeparableSubsurface
    【02】实时高逼真皮肤渲染02 次表面散射技术发展历史及技术详细解释 2
    Post-Processing Full-Screen Effects

    相关文章

      网友评论

          本文标题:从零开始在Unity中写一个可分离的次表面散射(Separabl

          本文链接:https://www.haomeiwen.com/subject/llqunctx.html