从零开始在Unity中写一个可分离的次表面散射(Separable Subsurface Scattering)着色器

上一次博客中实现了一个简单的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-sss、Unity_ScreenSpaceTechStack、separable-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();
        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 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 CalculateKernel(int nSamples, Vector3 strength, Vector3 falloff){
        List kernel = new List();

        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 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

这个就是把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

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

项目地址

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

你可能感兴趣的:(从零开始在Unity中写一个可分离的次表面散射(Separable Subsurface Scattering)着色器)