從零開始在Unity中寫一個(gè)可分離的次表面散射(Separable Subsurface Scattering)著色器

上一次博客中實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的PBR,既然提到了PBR笔横,又怎么能不提一下3S(Subsurface Scattering约计,次表面散射)。在Disney最初的論文里牙甫,3S只是PBR材質(zhì)中的一個(gè)變量掷酗,名叫subsurface,通過這個(gè)來控制次表面散射的程度窟哺。然而到了實(shí)時(shí)渲染領(lǐng)域泻轰,特別是游戲領(lǐng)域,這個(gè)東西被單獨(dú)提了出來且轨,相應(yīng)發(fā)展出了很多種技術(shù)來實(shí)現(xiàn)它浮声。理論這里我也不講了,《GPU Gems 3》:真實(shí)感皮膚渲染技術(shù)總結(jié)已經(jīng)講得非常棒了旋奢。

從實(shí)現(xiàn)上來說泳挥,有基于紋理空間的模糊,有基于屏幕空間的模糊至朗,有改進(jìn)改進(jìn)半透明陰影貼圖(Translucent Shadow Maps屉符,TSMs),有預(yù)積分的皮膚著色(Pre-Integrated Skin Shading)锹引,有結(jié)合延遲渲染技術(shù)(Deferred Single Scattering)的矗钟,還有最新的是路徑追蹤次表面散射(Path-Traced Subsurface Scattering),這種區(qū)別于傳統(tǒng)的光柵圖形學(xué)嫌变,用了光線追蹤技術(shù)吨艇,是基于Ray Marching的解決方案。

本文并非要實(shí)現(xiàn)以上技術(shù)腾啥,而是實(shí)現(xiàn)由動(dòng)視暴雪于2013年首先應(yīng)用的技術(shù)东涡,2年后他們把這種技術(shù)寫成論文,稱作Separable Subsurface Scattering倘待,可以叫它4S技術(shù)疮跑。它也是一種基于屏幕空間模糊的技術(shù),不過相比于之前的屏幕空間技術(shù)凸舵,它大大降低了消耗祸挪。原來的技術(shù)需要6次高斯模糊,而一次模糊需要x贞间,y方向都做一個(gè)pass贿条,6次就要12個(gè)pass來滿足需要”⒎拢現(xiàn)在4S技術(shù)只需要2個(gè)pass來做模糊,所以成為了現(xiàn)在游戲業(yè)界的主流技術(shù)整以,Unreal也對(duì)此進(jìn)行了集成胧辽。

另外,我參考(抄襲公黑?)了separable-sss邑商、Unity_ScreenSpaceTechStackseparable-sss-unity
以及Unity-Human-Skin-Shader-PC這四個(gè)項(xiàng)目凡蚜,才最終實(shí)現(xiàn)了4S人断,不過其中4S的核心技術(shù)我也不是很明白,屬于別人怎么做我也怎么做的階段朝蜘,如果以后搞懂了恶迈,可以再回來解釋。

首先谱醇,既然4S是一種基于屏幕空間的技術(shù)暇仲,那么到Unity里就是后處理效果了。本質(zhì)上這種技術(shù)就是屏幕空間模糊副渴,那么我們先創(chuàng)建一個(gè)后處理文件掛在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");//用一個(gè)數(shù)代表現(xiàn)當(dāng)前RT,_SceneID沒有用在任何地方奈附,這樣返回的數(shù)不會(huì)和其他沖突
    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這個(gè)方法,決定了這個(gè)模糊到底該怎么模糊煮剧,其余都是些Command Buffer的應(yīng)用斥滤,不過有兩個(gè)方法BlitStencilBlitSRT并不是Command Buffer里提供的,是用了C#的一個(gè)特性Extension Methods實(shí)現(xiàn)的勉盅,是這樣實(shí)現(xiàn)的

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項(xiàng)目里為了兼容延遲渲染寫了不少這樣的方法,我把這兩個(gè)對(duì)項(xiàng)目有用的方法拿了出來(我主要測(cè)試前向渲染菇篡,延遲渲染不怎么考慮)。
從代碼來看一喘,就是依靠KernelCalculator.CalculateKernel這個(gè)方法算出一個(gè)Kernel Array傳給shader(我叫它Unlit/SSS驱还,用來做x,y方向的2次模糊),利用這個(gè)shader產(chǎn)生的材質(zhì)把屏幕原來的圖像給模糊一下凸克,從實(shí)現(xiàn)上來說和普通的模糊特效實(shí)現(xiàn)過程差不了多少。

然后來看KernelCalculator.CalculateKernel這個(gè)方法萎战,實(shí)現(xiàn)如下

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

這個(gè)就是把separable-sss項(xiàng)目的C++代碼翻譯成了C#代碼蚂维,并且我把原項(xiàng)目里的注釋也copy過來了戳粒,如果哪天看懂了4S的核心算法,這個(gè)我也能懂了>_<蔚约。

最后來看用來模糊的那個(gè)shader(Unlit/SSS),這個(gè)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在通過深度以及傳進(jìn)來的Kernel Array做一些模糊的計(jì)算树枫,SSS就是具體兩個(gè)方向的模糊了砂轻。不過這里我并不明白DistanceToProjectionWindowDPTimes300的意義奔誓,有沒有知道的同學(xué)能解釋一下丝里?

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技術(shù)所模糊的那個(gè)(或幾個(gè))對(duì)象的shader(我用了自己上次寫的簡(jiǎn)陋版PBR著色器)里需要加入這樣一段來啟用模糊

Stencil{
            Ref 5
            Comp Always
            Pass Replace
        }

并且,我用的Unity2019.3版本里加了上一面那段stencil依然不能開啟模糊幌绍,必須要在shader最后加上Fallback才能起效故响,難道stencil的使用方法改變了?

最后放上效果圖

PBR效果伪冰,沒開4S模糊

感覺這個(gè)已經(jīng)很不錯(cuò)了樟蠕,貼圖模型做的非常好啊。下面是開了4S效果的圖


4S開啟

模糊了一下看起來暗了點(diǎn)吓懈,我們?cè)賮戆涯:{(diào)大點(diǎn)看看


更多的4S

感覺從一個(gè)中年大叔變年輕了靡狞,滿臉的膠原蛋白。。腮恩。

這里有個(gè)坑扒磁。。缸榛。如果我把相機(jī)的MSAA屬性設(shè)為Use Graphics Settings兰伤,那么渲染結(jié)果將是全黑(至少Windows平臺(tái)是這樣的),我還沒整明白為什么=_=!

項(xiàng)目地址

參考
Unity_SeparableSubsurface
【02】實(shí)時(shí)高逼真皮膚渲染02 次表面散射技術(shù)發(fā)展歷史及技術(shù)詳細(xì)解釋 2
Post-Processing Full-Screen Effects

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末均澳,一起剝皮案震驚了整個(gè)濱河市符衔,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌判族,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,084評(píng)論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件槽惫,死亡現(xiàn)場(chǎng)離奇詭異辩撑,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)各薇,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,623評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門君躺,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人晰洒,你說我怎么就攤上這事啥箭。” “怎么了砌滞?”我有些...
    開封第一講書人閱讀 163,450評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)贝润。 經(jīng)常有香客問我,道長(zhǎng)华畏,這世上最難降的妖魔是什么尊蚁? 我笑而不...
    開封第一講書人閱讀 58,322評(píng)論 1 293
  • 正文 為了忘掉前任,我火速辦了婚禮仑乌,結(jié)果婚禮上琴锭,老公的妹妹穿的比我還像新娘。我一直安慰自己决帖,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,370評(píng)論 6 390
  • 文/花漫 我一把揭開白布止剖。 她就那樣靜靜地躺著落君,像睡著了一般。 火紅的嫁衣襯著肌膚如雪皮获。 梳的紋絲不亂的頭發(fā)上纹冤,一...
    開封第一講書人閱讀 51,274評(píng)論 1 300
  • 那天,我揣著相機(jī)與錄音雁歌,去河邊找鬼知残。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的佳窑。 我是一名探鬼主播父能,決...
    沈念sama閱讀 40,126評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼溉委!你這毒婦竟也來了爱榕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,980評(píng)論 0 275
  • 序言:老撾萬榮一對(duì)情侶失蹤型宝,失蹤者是張志新(化名)和其女友劉穎絮爷,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體坑夯,經(jīng)...
    沈念sama閱讀 45,414評(píng)論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡柜蜈,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,599評(píng)論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了隶垮。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片秘噪。...
    茶點(diǎn)故事閱讀 39,773評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖蹋偏,靈堂內(nèi)的尸體忽然破棺而出至壤,到底是詐尸還是另有隱情,我是刑警寧澤像街,帶...
    沈念sama閱讀 35,470評(píng)論 5 344
  • 正文 年R本政府宣布京郑,位于F島的核電站葫掉,受9級(jí)特大地震影響跟狱,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜挪挤,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,080評(píng)論 3 327
  • 文/蒙蒙 一关翎、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧论寨,春花似錦爽茴、人聲如沸葬凳。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,713評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽胧沫。三九已至,卻和暖如春纯赎,著一層夾襖步出監(jiān)牢的瞬間南蹂,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,852評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工佑附, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留仗考,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,865評(píng)論 2 370
  • 正文 我出身青樓秃嗜,卻偏偏與公主長(zhǎng)得像顿膨,于是被迫代替她去往敵國(guó)和親叽赊。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,689評(píng)論 2 354