上一次博客中实现了一个简单的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<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的应用,不过有两个方法BlitStencil
和BlitSRT
并不是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就是具体两个方向的模糊了。不过这里我并不明白DistanceToProjectionWindow
和DPTimes300
的意义,有没有知道的同学能解释一下?
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
网友评论