从零开始在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-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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,427评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,551评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,747评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,939评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,955评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,737评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,448评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,352评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,834评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,992评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,133评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,815评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,477评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,022评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,147评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,398评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,077评论 2 355