參考文章: https://www.imgtec.com/blog/implementing-fast-ray-traced-soft-shadows-in-a-game-engine/
完成的工程: https://github.com/yangrc1234/ScreenSpaceShadow
(一個例子,注意靠近立柱的部分的陰影較為銳利,遠離的部分更加模糊)
Penumbra
現實生活中,距離遮擋物越近的地方,其陰影會更加銳利;反之則更加模糊。這一片介於被照亮和陰影中間的區域被稱為Penumbra。我們用如下公式去計算Penumbra的大小:
對於太陽光,我們可以認為Light Size / Total Distance是一個常數。當這個常數大時,模糊效果就會更大。
在實現過程中,我們生成兩張貼圖,一張是陰影貼圖,保存某個點上是否有陰影;另一個保存一個點到其遮擋物的距離。我們可以在一張貼圖里的不同通道保存這兩個信息。
生成貼圖完畢后,我們在第二個pass里對陰影貼圖做模糊。其中模糊的半徑根據該點上的遮擋物距離決定。如果一個點不在陰影中,則去搜索最靠近的陰影中的點(如果沒搜到,說明該點被完全照亮),然后用這個點的遮擋物距離進行模糊。搜索時我們直接在該點的水平方向或者垂直方向上的點進行搜索。
之后將模糊得到的結果和原有的屏幕陰影做Blend即可。
具體實現
我們使用2個CommandBuffer去實現這個效果。腳本都附在Directional Light下。
第一個CommandBuffer在LightEvent.BeforeScreenspaceMask
執行。在這一步我們渲染上面提到的陰影貼圖+遮擋物距離。
第二個CommandBuffer在LightEvent.AfterScreenspaceMask
執行。在這一步中,我們的渲染目標由Unity設置為了屏幕空間陰影貼圖。此時我們可以同時進行模糊以及Blend的操作。
這里分為兩個CommandBuffer有兩個原因,
- 除了這兩個事件中,默認的RenderTarget是屏幕空間陰影貼圖,我們似乎沒有辦法在其他地方將RenderTarget設置為這個屏幕空間陰影貼圖。
- 在第一個CommandBuffer渲染完陰影貼圖+遮擋物距離之后,此時RenderTarget已經改變了。而我們沒有辦法將RenderTarget重置成屏幕空間陰影貼圖。所以等到LightEvent.AfterScreenspaceMask的時候切換回RenderTarget吧。
聽起來有點捉雞,不知道是我太菜還是CommandBuffer太菜……
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
[RequireComponent(typeof(Light))]
public class ScreenSpaceShadow : MonoBehaviour {
public Shader sssShader;
private Light dlight { get {
return GetComponent<Light>();
} }
private Material _mat;
private Material mat {
get {
return _mat ?? (_mat = new Material(sssShader));
}
}
private CommandBuffer cmdBuf; //executed before shadow map.
private CommandBuffer afterCmdBuf;
private RenderTexture tempScreenSpaceShadow;
private void UpdateCommandBuffers() {
if (tempScreenSpaceShadow != null)
DestroyImmediate(tempScreenSpaceShadow);
tempScreenSpaceShadow = new RenderTexture(Screen.width, Screen.height, 0,RenderTextureFormat.RGFloat); //R channel stands for dentisy, G stands for occluder distance.
tempScreenSpaceShadow.filterMode = FilterMode.Point;
cmdBuf = new CommandBuffer();
cmdBuf.Blit(null, tempScreenSpaceShadow, mat, 0); //first pass. render a dentisy/occluder distance buffer.
afterCmdBuf = new CommandBuffer();
afterCmdBuf.Blit(tempScreenSpaceShadow, BuiltinRenderTextureType.CurrentActive, mat, 1); //second pass. blur the dentisy and blend final result into current screen shadow map.
}
//doesn't work for multi camera.
//I can't find a way to make it compatitable with multi camera.
//if you know how to access correct view matrix inside shader(Currently UNITY_MATRIX_V doesn't work in Unity 2017.1, or I misunderstand the meaning of UNITY_MATRIX_V? ), you can remove this, and multi camera will work.
private void Update() {
mat.SetMatrix("_WorldToView", Camera.main.worldToCameraMatrix);
}
private void OnEnable() {
UpdateCommandBuffers();
dlight.AddCommandBuffer(LightEvent.BeforeScreenspaceMask, cmdBuf);
dlight.AddCommandBuffer(LightEvent.AfterScreenspaceMask, afterCmdBuf);
}
private void OnDisable() {
dlight.RemoveCommandBuffer(LightEvent.BeforeScreenspaceMask, cmdBuf);
dlight.RemoveCommandBuffer(LightEvent.AfterScreenspaceMask, afterCmdBuf);
}
}
接下來是Shader的實現。之前的文章中我們已經提過了屏幕空間光線追蹤的原理以及實現。這里我直接貼出相關函數的代碼。
#ifndef _YRC_SCREEN_SPACE_RAYTRACE_
#define _YRC_SCREEN_SPACE_RAYTRACE_
//convenient function.
bool RayIntersect(float raya, float rayb, float2 sspt,float thickness) {
if (raya > rayb) {
float t = raya;
raya = rayb;
rayb = t;
}
#if 1 //by default we use fixed thickness.
float screenPCameraDepth = -LinearEyeDepth(tex2Dlod(_CameraDepthTexture, float4(sspt / 2 + 0.5, 0, 0)).r);
return raya < screenPCameraDepth && rayb > screenPCameraDepth - thickness;
#else
float backZ = tex2Dlod(_BackfaceTex, float4(sspt / 2 + 0.5, 0, 0)).r;
return raya < backZ && rayb > screenPCameraDepth;
#endif
}
bool traceRay(float3 start, float3 direction, float jitter, float4 texelSize,float maxRayLength, float maxStepCount, float pixelStride, float pixelThickness, out float2 hitPixel, out float marchPercent,out float hitZ,out float rayLength) {
//clamp raylength to near clip plane.
rayLength = ((start.z + direction.z * maxRayLength) > -_ProjectionParams.y) ?
(-_ProjectionParams.y - start.z) / direction.z : maxRayLength;
float3 end = start + direction * rayLength;
float4 H0 = mul(unity_CameraProjection, float4(start, 1));
float4 H1 = mul(unity_CameraProjection, float4(end, 1));
float2 screenP0 = H0.xy / H0.w;
float2 screenP1 = H1.xy / H1.w;
float k0 = 1.0 / H0.w;
float k1 = 1.0 / H1.w;
float Q0 = start.z * k0;
float Q1 = end.z * k1;
if (abs(dot(screenP1 - screenP0, screenP1 - screenP0)) < 0.00001) {
screenP1 += texelSize.xy;
}
float2 deltaPixels = (screenP1 - screenP0) * texelSize.zw;
float step; //the sample rate.
step = min(1 / abs(deltaPixels.y), 1 / abs(deltaPixels.x)); //make at least one pixel is sampled every time.
//make sample faster.
step *= pixelStride;
float sampleScaler = 1.0 - min(1.0, -start.z / 100); //sample is slower when far from the screen.
step *= 1.0 + sampleScaler;
float interpolationCounter = step; //by default we use step instead of 0. this avoids some glitch.
float4 pqk = float4(screenP0, Q0, k0);
float4 dpqk = float4(screenP1 - screenP0, Q1 - Q0, k1 - k0) * step;
pqk += jitter * dpqk;
float prevZMaxEstimate = start.z;
bool intersected = false;
UNITY_LOOP //the logic here is a little different from PostProcessing or (casual-effect). but it's all about raymarching.
for (int i = 1;
i <= maxStepCount && interpolationCounter <= 1 && !intersected;
i++,interpolationCounter += step
) {
pqk += dpqk;
float rayZMin = prevZMaxEstimate;
float rayZMax = ( pqk.z) / ( pqk.w);
if (RayIntersect(rayZMin, rayZMax, pqk.xy - dpqk.xy / 2, pixelThickness)) {
hitPixel = (pqk.xy - dpqk.xy / 2) / 2 + 0.5;
marchPercent = (float)i / maxStepCount;
intersected = true;
}
else {
prevZMaxEstimate = rayZMax;
}
}
#if 1 //binary search
if (intersected) {
pqk -= dpqk; //one step back
UNITY_LOOP
for (float gapSize = pixelStride; gapSize > 1.0; gapSize /= 2) {
dpqk /= 2;
float rayZMin = prevZMaxEstimate;
float rayZMax = (pqk.z) / ( pqk.w);
if (RayIntersect(rayZMin, rayZMax, pqk.xy - dpqk.xy / 2, pixelThickness)) { //hit, stay the same.(but ray length is halfed)
}
else { //miss the hit. we should step forward
pqk += dpqk;
prevZMaxEstimate = rayZMax;
}
}
hitPixel = (pqk.xy - dpqk.xy / 2) / 2 + 0.5;
}
#endif
hitZ = pqk.z / pqk.w;
rayLength *= (hitZ - start.z) / (end.z - start.z);
return intersected;
}
#endif
在兩個pass中,我們的vertex program是相同的。代碼如下:
struct appdata {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 csRay : TEXCOORD1; //unused during second pass.
};
v2f vert(appdata v) {
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
float4 cameraRay = float4(v.uv * 2.0 - 1.0, 1.0, 1.0);
cameraRay = mul(unity_CameraInvProjection, cameraRay);
o.csRay = cameraRay / cameraRay.w;
return o;
}
第一個pass的fragment program如下:
#define RAY_LENGTH 40.0 //maximum ray length.
#define STEP_COUNT 256 //maximum sample count.
#define PIXEL_STRIDE 4 //sample multiplier. it's recommend 16 or 8.
#define PIXEL_THICKNESS (0.04 * PIXEL_STRIDE) //how thick is a pixel. correct value reduces noise.
float4 fragDentisyAndOccluder(v2f i) : SV_Target //we return dentisy in R, distance in G
{
float decodedDepth = Linear01Depth(tex2D(_CameraDepthTexture, i.uv).r);
float3 csRayOrigin = decodedDepth * i.csRay;
float3 wsNormal = tex2D(_CameraGBufferTexture2, i.uv).rgb * 2.0 - 1.0;
float3 csNormal = normalize(mul((float3x3)_WorldToView, wsNormal));
float3 wsLightDir = -_LightDir;
float3 csLightDir = normalize(mul((float3x3)_WorldToView, wsLightDir));
float2 hitPixel;
float marchPercent;
float3 debugCol;
float atten = 0;
float2 uv2 = i.uv * float2(1024,1024);
float c = (uv2.x + uv2.y) * 0.25;
float hitZ;
float rayBump = max(-0.010*csRayOrigin.z, 0.001);
float rayLength;
bool intersectd = traceRay(
csRayOrigin + csNormal * rayBump,
csLightDir,
0, //don't need jitter here.
float4(1 / 991.0, 1 / 529.0, 991.0, 529.0), //texel size.
RAY_LENGTH,
STEP_COUNT,
PIXEL_STRIDE,
PIXEL_THICKNESS,
hitPixel,
marchPercent,
hitZ,
rayLength
);
return intersectd ? float4(1 , rayLength, 0, 1) : 0;
}
當我們沒有命中時,我們返回全0的結果,否則將R設置為1,同時在G通道上保存距離。
在我上面貼出的原文中,陰影貼圖可以在光線追蹤到透明物體時設置為0.x這樣的數值,但是我們的deferred rendering肯定沒有這樣的操作。(原文中的硬件是專門用於光線追蹤的硬件,它的光線追蹤也不是屏幕空間光線追蹤)所以這里的貼圖應該可以壓縮成一個通道,為0時表示沒有陰影,非0時表示到阻擋者的距離。
生成的貼圖:
第二個pass如下:
#define BLURBOX_HALFSIZE 8
#define PENUMBRA_SIZE_CONST 4
#define MAX_PENUMBRA_SIZE 8
#define DEPTH_REJECTION_EPISILON 1.0
fixed4 fragBlur(v2f i) :SV_TARGET{
float2 dentisyAndOccluderDistance = tex2D(_MainTex,i.uv).rg;
fixed dentisy = dentisyAndOccluderDistance.r;
float occluderDistance = dentisyAndOccluderDistance.g;
float maxOccluderDistance = 0;
float3 uvOffset = float3(_MainTex_TexelSize.xy, 0); //convenient writing here.
for (int j = 0; j < BLURBOX_HALFSIZE; j++) { //search on vertical and horizontal for nearest shadowed pixel.
float top = tex2D(_MainTex, i.uv + j * uvOffset.zy).g;
float bot = tex2D(_MainTex, i.uv - j * uvOffset.zy).g;
float lef = tex2D(_MainTex, i.uv + j * uvOffset.xz).g;
float rig = tex2D(_MainTex, i.uv - j * uvOffset.xz).g;
if (top != 0 || bot != 0 || lef != 0 || rig != 0) {
maxOccluderDistance = max(top, max(bot, max (lef, rig)));
break;
}
}
float penumbraSize = maxOccluderDistance * PENUMBRA_SIZE_CONST;
float camDistance = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv));
float projectedPenumbraSize = penumbraSize / camDistance;
projectedPenumbraSize = min(1 + projectedPenumbraSize, MAX_PENUMBRA_SIZE);
float depthtop = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv + j * uvOffset.zy));
float depthbot = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv - j * uvOffset.zy));
float depthlef = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv + j * uvOffset.xz));
float depthrig = LinearEyeDepth(tex2D(_CameraDepthTexture, i.uv - j * uvOffset.xz));
float depthdx = min(abs(depthrig - camDistance), abs(depthlef - camDistance));
float depthdy = min(abs(depthtop - camDistance), abs(depthbot - camDistance));
float counter = 0;
float accumulator = 0;
UNITY_LOOP
for (int j = -projectedPenumbraSize; j < projectedPenumbraSize; j++) { //xaxis
for (int k = -projectedPenumbraSize; k < projectedPenumbraSize; k++) { //yaxis
float depth = LinearEyeDepth(tex2Dlod(_CameraDepthTexture, float4(i.uv + uvOffset.xy * float2(j, k),0,0)));
if (depthdx * abs(j) + depthdy * abs(k) + DEPTH_REJECTION_EPISILON < abs(camDistance - depth)) //depth rejection
break;
counter += 1;
accumulator += tex2Dlod(_MainTex, float4(i.uv + uvOffset.xy * float2(j, k),0,0)).r;
}
}
return (1 - saturate(accumulator / counter));
}
大體內容還是很好理解的。首先我們根據遮擋距離計算penumbra的大小,然后除以z值,獲得屏幕空間下的penumbra的大小。然后根據這個大小去采樣同樣大小的屏幕空間的像素即可。
其中有一個depth rejection的操作,用於將跟當前點不是同一個平面上的采樣剔除掉。具體的操作是比較采樣點和當前點的深度,如果差值超過了一個閾值,則認為不在一個平面。這個閾值是通過當前點和其上下左右四個點的深度值計算得到的。具體的就不細說了。
這里返回的是光照值。此時生成的貼圖是這樣的:
因為我們希望原有的屏幕空間陰影和我們計算的結果結合。此時我們可以想到可以通過將兩張貼圖的值相乘得到最終結果。(兩張貼圖保存的都是光照度,陰影為0,光照為1,相乘后兩張貼圖的陰影融合)
我們設置Blend選項為Blend Zero SrcAlpha,此時公式為Src * Zero + Dst * SrcAlpha,即Dst * SrcAlpha,我們就得到了最終結果。
(注意后面的山的陰影,來自原本的屏幕空間陰影貼圖)
總結
以上就是我的屏幕空間陰影的實現。這個實現有諸多不完善的地方,比如
-
不支持多相機。
這個問題的根本原因是UNITY_MATRIX_V在shader中不知道為什么無效了。因此我們需要在腳本中手動設置世界到相機的矩陣。在之前的屏幕空間反射中,這樣的設置還沒有太大的問題,但是在這里我們使用CommandBuffer注入了Directional Light的流程進行渲染,這個CommandBuffer在所有相機渲染光照的時候都會執行一次。為了保證正確,我們需要在每次渲染的時候都設置一次對應相機的矩陣。但是我現在還沒有找到正確的,可以設置對應相機矩陣的方法。(OnRenderObject里使用Camera.current的矩陣進行設置並不work,不知道為什么)。 -
不支持Shadow Strength。
在Light的選項里有一個Shadow Strength。在渲染屏幕空間陰影貼圖時,根據這個值,渲染出來的陰影部分強度也是不同的。但是我們渲染出來的陰影會以相乘的方式融合進去,此時整張陰影貼圖的強度就變得不均勻了。也許有修改Blend選項之類的辦法來解決這個問題,我暫時沒想到。
(注意山上的陰影,原貼圖的部分是較淺,我們的部分是黑色) -
只支持Deferred rendering。
這個效果中需要Deferred rendering的點只有屏幕光線追蹤時,需要用到法線做一個起始點偏移。事實上我們是可以是可以通過深度貼圖來一定程度上重建一個屏幕空間的法線信息的,但是我還沒有嘗試過。因此理論上我們可以將該效果改造為同時適應forward rendering與deferred rendering的一個效果。