Custom SRP - Directional Shadows
1. 渲染阴影
渲染阴影常用 ShadowMap 技术,即阴影贴图,该图中存储了光在照射到一个表面前经过的距离。在该方向上超过该距离的,都不会被该光源照亮。
1.1 阴影设置
在渲染阴影前,需要确定渲染配置,包括:
最大渲染距离
阴影图的尺寸
我们首先要支持方向光阴影,因此先定义方向光阴影配置
using UnityEngine;
[System.Serializable]
public class ShadowSettings
{public enum MapSize {_256 = 256, _512 = 512, _1024 = 1024,_2048 = 2048, _4096 = 4096, _8192 = 8192}[Min(0f)]public float maxDistance = 100f;[System.Serializable]public struct Directional {public MapSize atlasSize;}public Directional directional = new Directional {atlasSize = MapSize._1024};
}
在 CustomRenderPipelineAsset 中定义配置对象
[SerializeField]ShadowSettings shadows = default;
将该配置传递给管线实例
ShadowSettings shadowSettings;
public CustomRenderPipeline (bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher,ShadowSettings shadowSettings)
{this.shadowSettings = shadowSettings;…
}
修改 CameraRenderer.Render 方法,接受 ShadowSettings 作为参数.然后,选择 ShadowSettings.maxDistance 和摄像机的 farClipPlane 中较小的,进行场景裁剪.
bool Cull(float maxShadowDistance)
{if(camera.TryGetCullingParameters(out ScriptableCullingParameters p)){p.shadowDistance = Mathf.Min(maxShadowDistance, camera.farClipPlane);cullingResults = context.Cull(ref p);return true;}return false;
}
1.2 阴影渲染类
定义一个 Shadows 类,专门完成阴影的渲染工作
首先定义类,并根据需要缓存下来渲染相关的对象,并创建 CommandBuffer.同时定义最大支持阴影的方向光数量.此外,需要记录下方向光阴影的渲染参数,并记录渲染方向光阴影的光源总数.
public class Shadows
{const string bufferName = "Shadows";CommandBuffer buffer = new CommandBuffer(){ name = bufferName };private ScriptableRenderContext context;CullingResults cullingResults;private ShadowSettings settings;private const int maxdirectionalLightCount = 1;private int directionalLightCount;struct ShadowedDirectionalLight{public int visibleLightIndex;}ShadowedDirectionalLight[] directionalLights = new ShadowedDirectionalLight[maxdirectionalLightCount];public void Setup(ScriptableRenderContext context, CullingResults cullingResults, ShadowSettings settings){this.context = context;this.cullingResults = cullingResults;this.settings = settings;directionalLightCount = 0;}public void ExecuteBuffer(){context.ExecuteCommandBuffer(buffer);buffer.Clear();}
}
然后定义接口,执行缓存方向光阴影参数的逻辑.该逻辑判断方向光是否需要渲染阴影,因此进行了条件判断:
- 是否超过最大方向光阴影光源数量
- 方向光是否开启了阴影
- 阴影强度是否是0
- 根据裁剪结果,判断是否有对象被该光源照射并产生阴影,这通过 CullingResults.GetShadowCasterBounds 来判断.如果返回 false 表示该光源产生的阴影对当前要渲染的对象没有影响.
public void ReserveDirectionalShadows(Light light, int visibleLightIndex)
{if(directionalLightCount < maxdirectionalLightCount &&light.shadows != LightShadows.None &&light.shadowStrength > 0f &&cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)){directionalLights[directionalLightCount++] = new ShadowedDirectionalLight(){visibleLightIndex = visibleLightIndex};}
}
修改 Lighting.cs,创建一个 Shadows 的实例.我们希望 shadow 的 profiling 是 Lighting 的子项目,因此在 shadows.Setup 之前开启剖析采样.
同时在设置方向光的函数中,调用 Shadows.ReserveDirectionalShadows 准备阴影渲染数据:
...
private Shadows shadows = new ShadowSplitData();public void Setup(ScriptableRenderContext context, CullingResults cullingResults, ShadowSettings settings)
{this.context = context;this.cullingResults = cullingResults;buffer.BeginSample(bufferName);shadows.Setup(context, cullingResults, settings);SetupLights();
}
...
private void SetupDirectionalLight(int index, ref VisibleLight light)
{if (dirLightCount > maxDirLightCount)return;dirLightCount++;dirLightColors[index] = light.finalColor;dirLightDirections[index] = -light.localToWorldMatrix.GetColumn(2);shadows.ReserveDirectionalShadows(light.light, index);
}
1.3 创建 shadow 图集
实现 Shadows.Render 接口,如果阴影方向光数量大于0,则进行阴影的渲染
public void Render()
{if (directionalLightCount > 0)RenderDirectionalShadows();// 如果不创建 _DirectionalShadowAtlas,在 WebGL2.0 会出错:其会绑定默认图,而默认图不是 shadowmap 兼容的格式.// 因此,如果没有阴影方向光,则创建一个1x1像素的 RenderTarget.elsebuffer.GetTemporaryRT(dirShadowAtlasId, 1, 1, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);
}
阴影需要被渲染到贴图上,因此需要创建 RenderTarget,并以"_DirectionalShadowAtlas" 来引用该贴图.
- RenderTarget 的尺寸,就是我们在 ShadowSettings 中定义的 atlasSize.
- 我们希望阴影图的精度尽量高,因此选择 32 位.
- 采样模式双线性就可以了
- RenderTexture格式,Unity 专门定义了 RenderTextureFormat.Shadowmap
- 渲染前,调用 CommandBuffer.SetRenderTarget.
- 因为我们不关心 shadowmap 上之前的内容,因此第二个参数是 DontCare
- 因为需要把渲染结果存储下来,供后续渲染采样,因此第三个参数是 Store
- 并进行清理.因为shadow map 只需要深度,因此不需要清理颜色缓冲区.
我们同时定义了清理 RenderTarget 的接口,在渲染完阴影后调用.
static int dirShadowAtlasId = Shader.PropertyToID("_DirectionalShadowAtlas");void RenderDirectionalShadows()
{int atlasSize = (int)settings.directional.atlasSize;buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize, 32,FilterMode.Bilinear, RenderTextureFormat.Shadowmap);buffer.SetRenderTarget(dirShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);buffer.ClearRenderTarget(true, false, Color.clear);
}public void Cleanup()
{buffer.ReleaseTemporaryRT(dirShadowAtlasId);ExecuteBuffer();
}
修改 CameraRenderer.Render
- 因为阴影设置并清理了 RenderTarget,因此,要在 context.SetupCameraProperties 前调用,否则后面渲染就会使用阴影的 RenderTarget
- 同时渲染完成后,调用 Lighting.Cleanup
public void Render(ScriptableRenderContext context, Camera camera, bool useDynamicBatching, bool useGPUInstancing, ShadowSettings shadows)
{...if (!Cull(shadows.maxDistance))return;PrepareBuffer();// 让 lighting 作为 SampleName 的子项目buffer.BeginSample(SampleName);ExecuteBuffer();// Setup(); 改到 lighting.Setup 调用之后lighting.Setup(context, cullingResults, shadows);buffer.EndSample(SampleName);Setup();DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);...
}
1.4 渲染
定义渲染方向光阴影的函数,并调用它.
由于只有一张方向管 shadow map,但是最多支持4个方向光,因此,根据要渲染阴影的方向光数量,将阴影图切分成多分:如果方向光多余一个,则切成4份.
void RenderDirectionalShadows()
{int atlasSize = (int)settings.directional.atlasSize;buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize, 32,FilterMode.Bilinear, RenderTextureFormat.Shadowmap);buffer.SetRenderTarget(dirShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);buffer.ClearRenderTarget(true, false, Color.clear);buffer.BeginSample(bufferName);ExecuteBuffer();int split = directionalLightCount <= 1 ? 1 : 2;int tileSize = atlasSize / split;for (int i = 0; i < directionalLightCount; i++){RenderDirectionalShadows(i, split, tileSize);}buffer.EndSample(bufferName);ExecuteBuffer();
}
- 渲染阴影需要填充 ShadowDrawingSettings,填入 cullingResults,方向光在 VisibleLight 数组中的索引,以及渲染投影方式.投影方式在 Unity 2022 引入,在 2023 又移除了.
- 阴影渲染是在光源位置,及朝向渲染场景的深度.然而方向光是没有位置,只有方向的,我们直接利用 Unity 提供的接口 ComputeDirectionalShadowMatricesAndCullingPrimitives,该接口有9个参数:
- 第一个参数, VisibleLight 索引
- 2,3,4 个参数,用来控制 cascade shadow.目前仅有一级,因此是0,1,Vector3.zero
- 第5个参数,阴影图的尺寸
- 6, 阴影近裁剪面,目前忽略,先填0
- 7,8 参数用来返回 视矩阵和投影矩阵
- 9 参数返回 ShadowSplitData,包含了如何裁剪投影对象的信息,需要将其拷贝到 ShadowDrawingSettings.
- 获得视口和投影矩阵后,立即应用
- 如果方向光数量大于1, shadow map 被切分,因此渲染前还要修改 viewport
- 最后,调用 context.DrawShadows 进行阴影渲染
void RenderDirectionalShadows(int index, int split, int tileSize)
{ShadowedDirectionalLight light = directionalLights[index]; var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex,BatchCullingProjectionType.Orthographic // 方向光阴影用正交投影 该参数 2022 引入,2023 又移除了);cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f,out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;SetViewport(index, split, tileSize);buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);ExecuteBuffer();context.DrawShadows(ref shadowSettings);
}void SetViewport(int index, int split, float tileSize)
{Vector2 offset = new Vector2(index%split, index/split);buffer.SetViewport(new Rect(offset.x*tileSize, offset.y*tileSize, tileSize, tileSize));
}
1.5 阴影 Pass
材质必须定义 ShadowCaster Pass ,才会渲染阴影
因为只需要深度,所以把颜色写入掩码设置为 0
阴影渲染相关的代码,我们定义在 ShadowCasterPass.hlsl 中
Pass
{Tags { "LightMode" = "ShadowCaster" }ColorMask 0HLSLPROGRAM#pragma target 3.5#pragma multi_compile_instancing#pragma shader_feature _CLIPPING#pragma vertex ShadowCasterPassVertex#pragma fragment ShadowCasterPassFragment#include "ShadowCasterPass.hlsl"ENDHLSL
}
阴影渲染和带光照的渲染类似,只是不需要输出颜色,因此,我们从 LitPass.hlsl 拷贝并改名,删除跟计算像素颜色相关的代码
像素着色器不写颜色,因此返回值改成 void,不需要语义修饰
ShadowCasterPass.hlsl
#ifndef CUSTOM_SHADOWCASTER_PASS_INCLUDED
#define CUSTOM_SHADOWCASTER_PASS_INCLUDED#include "../ShaderLibrary/Common.hlsl"UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float4 _BaseMap_ST;
float _Cutoff;
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);struct Attributes
{float3 positionOS : POSITION;float2 uv : TEXCOORD0;UNITY_VERTEX_INPUT_INSTANCE_ID
};struct Varyings
{float4 positionCS : SV_POSITION;float2 uv : TEXTCOORD0;UNITY_VERTEX_INPUT_INSTANCE_ID
};Varyings ShadowCasterPassVertex(Attributes input)
{Varyings output;UNITY_SETUP_INSTANCE_ID(input);UNITY_TRANSFER_INSTANCE_ID(input, output);float3 positionWS = TransformObjectToWorld(input.positionOS);output.positionCS = TransformWorldToHClip(positionWS);float4 baseMapST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);output.uv = input.uv * baseMapST.xy + baseMapST.zw;return output;
}void ShadowCasterPassFragment(Varyings input)
{UNITY_SETUP_INSTANCE_ID(input);float4 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);float4 base = texColor * UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
#if defined(_CLIPPING)clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endifreturn;
}#endif
现在可以渲染阴影了,在场景中摆放几个不透明物体,配置好方向光阴影参数。从渲染场景中看不出效果,是因为我们只渲染了阴影图,还没用于光照渲染。通过frame debugger可以看到渲染结果。
可以自己修改阴影配置的最大距离看看变化。
在场景中再添加几个方向光(我创建了2个),可以看到被分别渲染到阴影图的不同区域(通常叫chart)。
2. 采样阴影图
有了阴影图,就可以在光照pass中采样并判断像素是否在阴影中。
2.1 阴影矩阵
为了判断像素是否在阴影中,需要根据像素的世界位置,计算出该位置在阴影图上以的uv坐标,该坐标同时要考虑不同的 shadow chart。我们通过一个矩阵来完成uv 计算。
- 在shadows.cs中定义矩阵数组,为每个方向光记录变换矩阵
- 生成矩阵
- 在渲染阴影时,已经生成了阴影的 view projection 矩阵,这里直接使用 matrix = projection*view(unity 矩阵是左乘)。
- 需要考虑 reverse Z,可以通过 SystemInfo.useReverseZBuffer 来判断,如果为真,则反转Z,即Z轴乘以-1。
- 裁剪空间是立方体空间,中心点是0,范围是-1~1,而uv 坐标是0~1的,因此需要进行缩放。通过对矩阵XYZ * 0.5,再加上 0.5 来实现,即对矩阵左乘了一个 矩阵,主对角线上的 0.5 表示缩放,第三列的 0.5 表示平移。
- 还需要考虑不同chart的偏移,即左乘下面的矩阵
- 最后将矩阵上传到GPU全局constant。为方便,提前缓存常量的 shader id 。
void RenderDirectionalShadows()
{int atlasSize = (int)settings.directional.atlasSize;buffer.GetTemporaryRT(dirShadowAtlasId, atlasSize, atlasSize, 32,FilterMode.Bilinear, RenderTextureFormat.Shadowmap);buffer.SetRenderTarget(dirShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);buffer.ClearRenderTarget(true, false, Color.clear);buffer.BeginSample(bufferName);ExecuteBuffer();int split = directionalLightCount <= 1 ? 1 : 2;int tileSize = atlasSize / split;for (int i = 0; i < directionalLightCount; i++){RenderDirectionalShadows(i, split, tileSize);}// 设置将坐标从世界空间变换到阴影贴图空间(坐标)到矩阵buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);buffer.EndSample(bufferName);ExecuteBuffer();
}void RenderDirectionalShadows(int index, int split, int tileSize)
{ShadowedDirectionalLight light = directionalLights[index]; var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex,BatchCullingProjectionType.Orthographic // 方向光阴影用正交投影 该参数 2022 引入,2023 又移除了);cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, 0, 1, Vector3.zero, tileSize, 0f,out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;Vector2 offset = SetViewport(index, split, tileSize);buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);ExecuteBuffer();context.DrawShadows(ref shadowSettings);// 生成将世界坐标变换到阴影贴图UV坐标到矩阵dirShadowMatrices[index] = ConvertToAtlasMatrix(viewMatrix * projectionMatrix, offset, split);
}Matrix4x4 ConvertToAtlasMatrix(Matrix4x4 mat, Vector2 offset, int split)
{// 如果启用了 reverse z 要对 Z 轴取反if (SystemInfo.usesReversedZBuffer){mat.m20 = -mat.m20;mat.m21 = -mat.m21;mat.m22 = -mat.m22;mat.m23 = -mat.m23;}// 裁剪坐标是个立方体坐标,范围从 -1 到 1,中心点是 0,而UV坐标是 0-1,因此进行偏移缩放float scale = 1.0f / split;mat.m00 = (0.5f * (mat.m00 + mat.m30) + offset.x * mat.m30) * scale;mat.m01 = (0.5f * (mat.m01 + mat.m31) + offset.x * mat.m31) * scale;mat.m02 = (0.5f * (mat.m02 + mat.m32) + offset.x * mat.m32) * scale;mat.m03 = (0.5f * (mat.m03 + mat.m33) + offset.x * mat.m33) * scale;mat.m10 = (0.5f * (mat.m10 + mat.m30) + offset.y * mat.m30) * scale;mat.m11 = (0.5f * (mat.m11 + mat.m31) + offset.y * mat.m31) * scale;mat.m12 = (0.5f * (mat.m12 + mat.m32) + offset.y * mat.m32) * scale;mat.m13 = (0.5f * (mat.m13 + mat.m33) + offset.y * mat.m33) * scale;mat.m20 = 0.5f * (mat.m20 + mat.m30);mat.m21 = 0.5f * (mat.m21 + mat.m31);mat.m22 = 0.5f * (mat.m22 + mat.m32);mat.m23 = 0.5f * (mat.m23 + mat.m33);return mat;
}Vector2 SetViewport(int index, int split, float tileSize)
{Vector2 offset = new Vector2(index%split, index/split);buffer.SetViewport(new Rect(offset.x*tileSize, offset.y*tileSize, tileSize, tileSize));return offset;
}
2.2 存储每个光源的阴影参数
要为一个光源采样阴影图,需要知道该光源在阴影图上的 atlas 索引.我们在 Shadows.ReserveDirectionalShadows 函数中返回需要的数据,用一个 Vector2返回, x 表示 chart index, y 表示阴影强度
public Vector2 ReserveDirectionalShadows(Light light, int visibleLightIndex)
{if(directionalLightCount < maxdirectionalLightCount &&light.shadows != LightShadows.None &&light.shadowStrength > 0f &&cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)){directionalLights[directionalLightCount] = new ShadowedDirectionalLight(){visibleLightIndex = visibleLightIndex};return new Vector2(light.shadowStrength, directionalLightCount++);}return Vector2.zero;
}
在 Lighting.cs 中,缓存方向光阴影数据在 shader 中的常量ID,并定义Vector4 数组,
int dirLightShadowDataID = Shader.PropertyToID("_DirLightShadowData");
Vector4[] dirLightShadowData = new Vector4[maxDirLightCount];
缓存阴影参数,
private void SetupDirectionalLight(int index, ref VisibleLight light)
{...dirLightShadowData[index] = shadows.ReserveDirectionalShadows(light.light, index);
}
最后更上传到 GPU
public void SetupLights()
{...buffer.SetGlobalVectorArray(dirLightDirectionID, dirLightDirections);buffer.SetGlobalVectorArray(dirLightShadowDataID, dirLightShadowData);...
}
然后,在 Light.hlsl 中定义阴影渲染参数
CBUFFER_START(_Lights)
int _DirLightCount;
float4 _DirLightColors[MAX_DIR_LIGHT_COUNT];
float4 _DirLightDirections[MAX_DIR_LIGHT_COUNT];
float4 _DirLightShadowData[MAX_DIR_LIGHT_COUNT];
CBUFFER_END
2.3 阴影 hlsl 文件
将阴影采样的着色器逻辑,实现到专门的 shadow.hlsl 中,并定义阴影图,采样器,阴影变换矩阵:
#ifndef SHADOW_INCLUDED
#define SHADOW_INCLUDED// TEXTURE2D_SHADOW 与 TEXTURE2D 没什么不同,只是为了代码清晰
// _DirectionalShadowAtlas 是 Shadows.cs 定义的名字
TEXTURE2D_SHADOW(_DirectionalShadowAtlas)
// 显式定义采样器采样模式,对于阴影图来说,这是唯一可用的模式
#define SHADOW_SAMPLER sampler_linear_clamp_compare
// SAMPLER_CMP 定义的采样器执行特殊的采样逻辑, 对于深度来说,普通的 SAMPLER 的双线性过滤并不合理
SAMPLER_CMP(SHADOW_SAMPLER)CBUFFER_START(_Shadows)
// 将世界坐标变换成阴影图 UV 坐标的矩阵
// _DirLightShadowMatrices 是 Shadows.cs 中定义的名字
float4x4 _DirLightShadowMatrices[MAX_DIR_LIGHT_COUNT];
CBUFFER_END#endif
在 LitPass.hlsl 中包含该文件
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Shadows.hlsl"
2.4 采样阴影图
采样阴影,需要知道每个光源的阴影参数.在Shadow.hlsl 定义一个结构体来存储这些参数:
struct DirShadowData
{float strength;int tileIndex;
}
采样阴影,需要知道像素的世界坐标,将其添加到 Surface 结构体中,然后在像素着色器中为其赋值
struct Surface
{float3 position;float3 normal;...
阴影图采样的结果,是考虑阴影时,有多少光到达表面,是一个 0-1 之间的值,通常叫做 attenuation factor 衰减系数.如果像素在阴影内,结果是0,如果不在阴影内,结果是 1 ,如果在 0-1 之间,表示部分在阴影内.
此外,阴影强度可能会被减弱,可能是为了某正艺术效果,可能是为了表现半透明物体的阴影.当阴影强度降到0时,衰减将变成1(不影响光照).我们用强度进行差值,获得衰减
如果阴影强度小于等于0,则不需要采样阴影,计算衰减了,直接返回 1 即可. 1表示光照不受阴影影响
在 Shadow.hlsl 中定义采样函数,将坐标变换到阴影图采样空间,并进行采样
float SampleDirShadowAtlas(float3 postionSTS)
{return SAMPLE_TEXTURE2D_SHADOW(_DirLightShadowAtlas, SHADOW_SAMPLER, postionSTS);
}float GetDirShadowAttenuation(DirShadowData shadowData, Surface surfaceWS)
{if(shadowData.strength <= 0)return 1;float3 positionSTS = mul(_DirLightShadowMatrices[shadowData.tileIndex], float4(surfaceWS.position,1.0)).xyz;float shadow = SampleDirShadowAtlas(positionSTS);return lerp(1,shadow, shadowData.strength);
}
2.5 对光照进行衰减
上面计算了衰减值,这里把衰减值记录到 Light.hlsl 中的光源结构里
struct Light
{float3 color;float3 direction;float attenuation; // 阴影对光照产生的衰减
};
增加一个函数获取阴影参数,现在我们主要传入了强度,以及阴影的 chart 索引
DirShadowData GetDirectionalLightShadowData(int index)
{DirShadowData shadowData;shadowData.strength = _DirLightShadowData[index].x;shadowData.tileIndex = _DirLightShadowData[index].y;return shadowData;
}
在获取光源数据的函数中,针对每个像素,计算其阴影衰减,因此该函数需要增加 Surface 参数,参数内的值是世界空间的
Light GetDirectionalLight(int index, Surface surfaceWS)
{Light light;light.color = _DirLightColors[index].rgb;light.direction = normalize(_DirLightDirections[index].xyz);DirShadowData shadowData = GetDirectionalLightShadowData(index);light.attenuation = GetDirShadowAttenuation(shadowData, surfaceWS);return light;
}
然后修改 Lighting.hlsl.因为 GetDirectionalLight 增加了 Surface 参数,因此这里的调用也要把参数加上
float3 GetLighting(Surface surfaceWS, BRDF brdf)
{float3 color = 0.0;for(int i = 0; i < GetDirectionalLightCount(); ++i){Light light = GetDirectionalLight(i, surfaceWS); // 传入 surface in world spacecolor += GetLighting(surfaceWS, brdf, light);}return color;
}
上面的代码, GetDirectionalLight 返回的 Light 结构,包含了阴影衰减系数,要在计算光照的函数 GetLighting 中应用该系数:
float3 IncomingLight(Surface surface, Light light)
{return saturate(dot(surface.normal, light.direction) * light.attenuation) * light.color;
}
阴影有了,但是很糟糕,一些不该有阴影的地方,出现了阴影条带.这是因为自阴影,产生的原因的阴影图的分辨率,通过调高分辨率会有所改变,但是无法彻底消除,自阴影的问题后续解决.通过这种失真,我们可以看到阴影图的覆盖情况.
例如,可以看到阴影图仅覆盖了部分可见区域,该区域可以通过 max shadow distance 来控制,改变该值导致阴影区域变小或变大.阴影图是沿着光源方向而不是摄像机方向.一些阴影超过了最大距离,一些地方的阴影缺失了,而且当采样到边缘时,会有奇怪的效果.当只有一个光源时, clamp 效果时正常的.但是当有多个光源时,采样会跨过边界,采样到其它光源的阴影图上.
3. 级连阴影
方向光阴影会影响 max shadow distance 范围内的所有像素,最终覆盖很大的范围。同时,方向光阴影图渲染是正交视图,每个图素的大小都是相同的。如果尺寸过大,单个图书就清晰可见,导致阴影边缘锯齿严重,而小阴影可能会消失。可以通过增大阴影图尺寸缓解,但作用有限。
当使用透视相机时,距离越远,图素越小。在某个可视距离上,一个图素大小和像素大小一样,这时阴影图的分辨率就是理论上最优的。距离摄像机越近,阴影图分辨率越高,越远就越低。理想情况下,根据阴影接收的像素的离相机的距离,阴影图拥有不同的分辨。
Cascaded shadow map 级联阴影图就是解决方案。其思想是阴影会被渲染多次,每个光源会被渲染到多个 chart 上,即级联。第一级覆盖靠近摄像机的一小部分区域,后续的级联阴影用相同尺寸覆盖更大区域,然后为像素选择最合适的一级阴影。
3.1 配置
unity 每个方向光最多支持4级级联阴影,目前我们的阴影只有一级,通过给方向光阴影配置增加一个滑动条,提供配置功能。所有方向光阴影都使用同一个配置。
每级级联阴影覆盖部分区域,直到最大阴影距离。通过滑动条为前三级精确配置范围,第四级直到最大距离,因此不需要配置。最多支持四级,默认每级为 0.1, 0.25, 0.5。
接口 ComputeDirectionalShadowMatricesAndCullingPrimitives 需要我们将 ratios 打包到 Vector3 中,作为参数传递进去,为了方便,我们增加一个属性 CascadeRatios 来返回该打包数据。
[System.Serializable]public struct Directional{public MapSize atlasSize;[Range(1,4)] public int cascadeCount;[Range(0f, 1f)] public float cascadeRatio1;[Range(0f, 1f)] public float cascadeRatio2;[Range(0f, 1f)] public float cascadeRatio3;Vector3 CascadeRatios => new Vector3(cascadeRatio1, cascadeRatio2, cascadeRatio3);}public Directional directional = new Directional { atlasSize = MapSize._1024, cascadeCount = 4, cascadeRatio1 = 0.1f, cascadeRatio2 = 0.25f, cascadeRatio3 = 0.5f };
界面将更新为:
3.2 渲染级联阴影
每个级联阴影都需要自己的变换矩阵,因此阴影矩阵的数组大小,得是最大阴影方向光数量乘以最大级联数,它们都是4,总数就是16.
Matrix4x4[] dirShadowMatrices = new Matrix4x4[maxdirectionalLightCount*maxCascadeCount];
同样需要修改 Shadow.hlsl 中对应的常量声明
#define MAX_DIR_SHADOW_COUNT 4
#define MAX_CASCADE_COUNT 4CBUFFER_START(_Shadows)
// 将世界坐标变换成阴影图 UV 坐标的矩阵
// _DirShadowMatrices 是 Shadows.cs 中定义的名字
float4x4 _DirShadowMatrices[MAX_DIR_SHADOW_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
修改完后,Unity 会报警告,提示 shader array size 改变了,但是由于正在使用该 shader 渲染,所以不能更新。这时重启 Unity 可以解决该问题。
我们在 Shadows.ReserveDirectionalShadows 接口中,返回了光源 shadow tile 的偏移,由于每个光源可能有多个 tile ,因此需要乘以配置的级联阴影数量
public Vector2 ReserveDirectionalShadows(Light light, int visibleLightIndex)
{...return new Vector2(light.shadowStrength, settings.directional.cascadeCount * directionalLightCount++);...
}
同样的,阴影图切分的 tile 总数,也要乘以级联数,意味着最多以4(4x4)划分,成为16个 tile。
void RenderDirectionalShadows(){...int tiles = directionalLightCount * settings.directional.cascadeCount;int split = tiles <= 1 ? 1 : tiles <= 4 ? 2 : 4; // 1x1, 2x2, or 4x4 gridint tileSize = atlasSize / split;...}
现在,为方向光渲染阴影时,需要为它的每个 cascade 渲染一次
void RenderDirectionalShadows(int index, int split, int tileSize){ShadowedDirectionalLight light = directionalLights[index]; var shadowSettings = new ShadowDrawingSettings(cullingResults, light.visibleLightIndex,BatchCullingProjectionType.Orthographic // 方向光阴影用正交投影 该参数 2022 引入,2023 又移除了);int cascadeCount = settings.directional.cascadeCount;int tileOffset = index * cascadeCount;Vector3 cascadeRatios = settings.directional.CascadeRatios;for(int i = 0; i < cascadeCount; i++){cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount, cascadeRatios, tileSize, 0f,out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;int tileIndex = tileOffset + i;Vector2 offset = SetViewport(tileIndex, split, tileSize);buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);ExecuteBuffer();context.DrawShadows(ref shadowSettings);// 生成将世界坐标变换到阴影贴图UV坐标到矩阵dirShadowMatrices[tileIndex] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix, offset, split);}}
我们的场景里只有两个方向光,所以只用到了2*4,一半的阴影图。
3.3 裁剪球 Culling Spheres
Unity 通过为每个级联阴影创建一个裁剪球来确定其覆盖范围。由于阴影的投影是方形正交的,所以非常接近它们的裁剪球,但是也会覆盖周围的空间,这就是为什么在裁剪区域外面还能看到阴影。同样的,光源方向不会影响到球,因此所有方向光最终使用同样的裁剪球。
在决定采样哪个 cascade 时,也需要这些球的信息,因此需要上传到 GPU。缓存下级联数量和裁剪球的 shader id,并创建缓存裁剪球的数组
static int cascadeCountId = Shader.PropertyToID("_CascadeCount");
static int cascadeCullingSphereID = Shader.PropertyToID("_CascadeCullingSpheres");Vector4[] cascadeCullingSpheres = new Vector4[maxCascadeCount];
在渲染放相关阴影的代码中,ComputeDirectionalShadowMatricesAndCullingPrimitives
接口通过 SplitData 已经返回了裁剪球,我们直接缓存下来。由于每个 cascade 的渲染,用的都是同样的裁剪球,因此我们只在第一个灯光渲染阴影时记录。
我们要 shader 中检测像素是否在裁剪球内,可以通过比较像素到球心的距离的平方和球的半径的平方,因此我们缓存下球的半径的平方,避免在 shader 中为每个像素计算。注:裁剪球的坐标是 x,y,z,半径是 w。
...
// 设置级联球体的中心和半径,所有光源都是一样的,因此只需要在第一个级联中设置
if (index == 0)
{// cullingSphere.x, y, z 是球心坐标, w 是半径Vector4 cullingSphere = splitData.cullingSphere;// 我们在shader中要比较距离的平方和半径的平方,因此这里存储半径的平方cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[i] = cullingSphere;
}
...
将收集到的数据上传到GPU
void RenderDirectionalShadows()
{...// 向GPU上传数据// 设置将坐标从世界空间变换到阴影贴图空间(坐标)到矩阵buffer.SetGlobalMatrixArray(dirShadowMatricesId, dirShadowMatrices);// 级联数量buffer.SetGlobalInt(cascadeCountId, settings.directional.cascadeCount);// 设置级联剔除球体buffer.SetGlobalVectorArray(cascadeCullingSphereID, cascadeCullingSpheres);...
}
3.4 采样级联阴影
上面CPU已经将数据上传到GPU,shader 中需要定义对应的常量来接受,在 Shadow.hlsl 中
CBUFFER_START(_Shadows)
int _CascadeCount; // 级联数量,通常是 1-4
float4 _CascadeCullingSpheres[MAX_CASCADE_COUNT]; // 级联剔除球体,每个级联一个球体
// 将世界坐标变换成阴影图 UV 坐标的矩阵
// _DirShadowMatrices 是 Shadows.cs 中定义的名字
float4x4 _DirShadowMatrices[MAX_DIR_SHADOW_COUNT * MAX_CASCADE_COUNT];
CBUFFER_END
每个像素都需要 cascade index 以决定采样坐标,因此在 Shadow.hlsl 中定义结构体 ShadowData 来存储它,后面会加入更多数据。以及提供接口 GetShadowData 来返回世界空间表面(像素)的阴影数据
struct ShadowData
{int cascadeIndex; // 级联索引,0-3
};// 获取方向光阴影数据
// 为表面选择一个阴影级联
ShadowData GetShadowData(Surface surfaceWS)
{ShadowData shadowData;int i;for (i = 0; i < MAX_CASCADE_COUNT; ++i){float4 sphere = _CascadeCullingSpheres[i];float dist = DistanceSquared(surfaceWS.position, sphere.xyz);if (dist <= sphere.w)break;}shadowData.cascadeIndex = i;return shadowData;
}
在Light.hlsl 中,获取方向光阴影数据的接口中,确定 tile 时,增加 cascade index 作为偏移
获取光源数据的接口中,也要增加参数,以进行传递
DirShadowData GetDirectionalLightShadowData(int index, ShadowData shadowData)
{DirShadowData shadowData;shadowData.strength = _DirLightShadowData[index].x;// y 是该光源的第一个 tile 的索引, + shadowData.cascadeIndex 表示该光源的第几个级联阴影 tileshadowData.tileIndex = _DirLightShadowData[index].y + shadowData.cascadeIndex;return shadowData;
}Light GetDirectionalLight(int index, Surface surfaceWS, ShadowData shadowData)
{Light light;light.color = _DirLightColors[index].rgb;light.direction = normalize(_DirLightDirections[index].xyz);DirShadowData shadowData = GetDirectionalLightShadowData(index, shadowData);light.attenuation = GetDirShadowAttenuation(shadowData, surfaceWS);return light;
}
在 Lighting.hlsl 中,计算像素光照前,获取阴影数据
float3 GetLighting(Surface surfaceWS, BRDF brdf)
{// 确定级联阴影,同一个像素的所有光源都使用相同的级联阴影ShadowData shadowData = GetShadowData(surfaceWS);float3 color = 0.0;for(int i = 0; i < GetDirectionalLightCount(); ++i){Light light = GetDirectionalLight(i, surfaceWS, shadowData);color += GetLighting(surfaceWS, brdf, light);}return color;
}
现在阴影好了一些。弧形的变化代表了2个 cascade 的边界。
可以通过直接返回 cascadeIndex 来查看 cascade 覆盖情况:
Light GetDirectionalLight(int index, Surface surfaceWS, ShadowData shadowData)
{...//light.attenuation = GetDirShadowAttenuation(dirShadowData, surfaceWS);light.attenuation = shadowData.cascadeIndex * 0.25f;return light;
}
黑色是第一级,以此类推。最远处没有被覆盖
3.5 阴影采样裁剪
当像素超出最后一级 cascade 后,cascade index 无效,如下图顶部黑色,灰色部分,都是不对的
过给 ShadowData 增加 strength ,当没有有效的 cascade 时,使其为 0
ShadowData GetShadowData(Surface surfaceWS)
{ShadowData shadowData;shadowData.strength = 1.0f; // 默认强度为 1.0,表示有阴影...shadowData.cascadeIndex = i;if (i == _CascadeCount)shadowData.strength = 0.0f; // 如果没有找到合适的级联,则强度为 0,表示没有阴影return shadowData;
}
然后在 Light.hlsl 中,查询光源的阴影强度时,应用该强度因子
DirShadowData GetDirectionalLightShadowData(int index, ShadowData shadowData)
{DirShadowData dirShadowData;// 应用级联阴影强度默认为1,像素超出级联范围时强度为0dirShadowData.strength = _DirLightShadowData[index].x * shadowData.strength;// y 是该光源的第一个 tile 的索引, + shadowData.cascadeIndex 表示该光源的第几个级联阴影 tiledirShadowData.tileIndex = _DirLightShadowData[index].y + shadowData.cascadeIndex;return dirShadowData;return dirShadowData;
}
最后,解决该问题
3.6 最大阴影距离
一些最大阴影距离的经验法线,一些投影对象虽然还在最后一级的裁剪球内,但是阴影会突然消失。这是因为最外层的裁剪球并不是精确匹配最大阴影距离,而是略微扩张了一些。这种不一致性,在 max shadow distance 的值比较小时尤为明显。
如果像素距离在最大距离之外,则不采样阴影图,这样就可以解决该问题。要这样做,我们还需要把最大阴影距离上传到 GPU,同样的,在CPU端缓存 shader 常量ID,并在渲染时提交。在GPU端声明常量,并使用它。
void RenderDirectionalShadows()
{...// 设置级联剔除球体buffer.SetGlobalVectorArray(cascadeCullingSphereID, cascadeCullingSpheres);// 设置阴影距离buffer.SetGlobalFloat(shadowDistanceId, settings.maxDistance);...
}
最大阴影距离是视口空间的深度,而不是到摄像机的距离,因此执行距离裁剪,需要像素的深度,将该参数添加到 Surface.hlsl 中
struct Surface
{...float smoothness;float depth;
};
在像素着色器中,填充 Surface 数据时,计算像素的深度
float4 LitPassFragment(Varyings input) : SV_TARGET
{...surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);surface.depth = -TransformWorldToView(input.positionWS).z; // 负值表示深度...
}
在获取阴影数据时,基于深度确定阴影强度
// 获取方向光阴影数据
// 为表面选择一个阴影级联
ShadowData GetShadowData(Surface surfaceWS)
{ShadowData shadowData;// 计算阴影强度,如果距离大于阴影距离,则强度为 0,否则默认为 1.0shadowData.strength = surfaceWS.depth > _ShadowDistance ? 0.0f : _1.0f;...return shadowData;
}
效果如下图,超出部分被裁剪掉了(平头)了
3.7 阴影淡出
当阴影超出最大距离时突然裁切掉太显眼了,因此我们通过线性淡出来让该过程变得平滑一些。在到达最大距离前开始淡出,到达最大距离时变成0.通过函数 (1-d/m)/f 来将这个过程钳制到0-1之间。其中 d 是像素的深度,m 是最大距离, f 是淡出范围,淡出距离=f*m。
给阴影配置增加一个属性 distanceFade,并且修饰它们最小值不能小于等0
public class ShadowSettings
{[Min(0.001f)] public float maxDistance = 100f;[Range(0.001f, 1f)] public float distanceFade = 0.1f;...
}
前面我们增加了 _ShadowDistance Shader 常量,现在我们要增加一个淡出参数,因此我们将该常量替换成一个 Vector4,并改名为 _ShadowDistanceFade。我们提前用 1 除以参数,以避免在 shader 中的除法,shader 中每个像素计算多次,因此改成乘法提升效率。
void RenderDirectionalShadows()
{...// 设置级联剔除球体buffer.SetGlobalVectorArray(cascadeCullingSphereID, cascadeCullingSpheres);// 设置阴影淡出参数buffer.SetGlobalVector(shadowDistanceFadeId, new Vector4(1.0f/settings.maxDistance, 1.0f/settings.distanceFade, .0f, .0f));...
}
同时修改 shader 相关代码,
CBUFFER_START(_Shadows)
...
float4 _ShadowDistanceFade; // 阴影距离,排除距离过远的像素,禁止采样阴影
CBUFFER_END
并添加计算 fade 强度的函数:
// 根据像素的视口空间的深度,scale(1/maxShadowDistance) 和 fade(1/fade in cpu) 计算阴影强度
// 使阴影在到达最大距离时强度变为0
float FadedShadowStrength(float distance, float scale, float fade)
{return saturate((1.0-distance*scale)*fade);
}// 获取方向光阴影数据
// 为表面选择一个阴影级联
ShadowData GetShadowData(Surface surfaceWS)
{ShadowData shadowData;// 计算阴影强度,如果距离大于阴影距离,则强度为 0,否则默认为 1.0shadowData.strength = FadedShadowStrength(surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);...
}
可以看到,在max shadow distance 上的淡出效果
3.8 Cascade 淡出
上图中,可以看出,左右两侧的阴影还是突然消失的,所以对于 cascade 我们也可以用同样的方法,在最后一级 cascade 的边缘执行淡出。给方向光加入 cascadeFade 配置参数:
[System.Serializable]
public struct Directional
{ ...[Range(0.001f, 1f)] public float cascadeFade;...
}public Directional directional = new Directional { ...cascadeFade = 0.1f
};
cascade 的淡出,基于 culling sphere 半径的平方,因此不是线性的。
差异不大,但是为了保持配置的淡出比例不变,计算 f 为:
,然后取倒数,并存储到 _ShadowDistanceFade.z 分量中
// 设置阴影淡出参数
float f = 1 - settings.directional.cascadeFade; // 1- f
buffer.SetGlobalVector(shadowDistanceFadeId, new Vector4(1.0f/settings.maxDistance, 1.0f/settings.distanceFade, 1/(1-f*f), .0f));
在 shader 中,到最后一级级联时,计算并应用强度
ShadowData GetShadowData(Surface surfaceWS)
{...if (distanceSqr < sphere.w){if (i == _CascadeCount - 1){shadowData.strength *= FadedShadowStrength(distanceSqr, 1.0 / sphere.w, _ShadowDistanceFade.z);}break;}...
}
现在可以看到,两侧了执行了淡出
4. 阴影质量
我们实现了阴影渲染的功能,然后开始提升渲染质量。最明显的问题如上图,是毛刺现象,这是由那些不是完美对齐方向光的表面的错误的自阴影导致的。随着表面与方向光接近平行,毛刺会变得更加严重。
增大阴影图的大小会降低图素在世界空间中的大小,因此毛刺也会变小,但是毛刺的数量也会变多,因此无法通过该方法解决问题。
4.1 深度偏移
由多种方法可以减轻毛刺。最简单的是给阴影投射一个深度偏移,使其向光的方向上偏移,错误的自阴影就没有了。可以在渲染阴影前,调用 Buffer.SetGlobalDepthBias 添加一个全局的深度偏移,并在阴影渲染完后重新设置回 0。该深度偏移是应用在裁剪空间的,一个很小的倍数值,具体依赖阴影图的格式。我们可以给它一个很大的数值比如 50000,看看它的原理。该函数还有第二个参数,目前设置为0。
void RenderDirectionalShadows(int index, int split, int tileSize)
{...// 渲染每级 cascade 的阴影for (int i = 0; i < cascadeCount; i++){...buffer.SetGlobalDepthBias(500000, 0);ExecuteBuffer();context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0, 0);...}
}
常数偏移比较简单,但只能消除差不多正面的失真。要移除多有的毛刺,需要以一个特别大的数值,如 500000:
然而,由于深度偏移将阴影”推远“了,因此采样的阴影也在方向上移动了。
而且偏移需要足够大才能移除大部分毛刺,使阴影看起来跟投影的物体”脱离“,导致 Perter-Panning 的视觉效果(即指物体与阴影过于分离)。
另一个方法是应用一个斜度缩放偏移 - slop-scale bias,该值由 SetGlobalDepthBias 的第二个参数传入一个非零值。该值用来根据X和Y轴的绝对裁剪空间深度倒数中的最大值进行缩放。因此对于正对光源的像素,其值是0。如果光在一个或两个轴上以45°照射在表面上,那么值就是1,到光线和像素法线的点积是0时(垂直了),则是一个趋于无限大的值。因此偏移会根据需要增加,但是没有上限。因此只需要一个较小的系数就可以消除毛刺,比如3
Slop scale bias 很高效,但是不符合直觉。为了达到我们想要的效果,需要多做几次实验,需要在毛刺和 peter-panning 效应之间的一个平衡。先禁用 SetGlobalDepthBids ,来尝试更加直觉和可预测的方法。
4.2 级联数据
因为毛刺的大小与世界空间屠苏的大小相关,一种在各种情况下都能保持效果一致的方式就必须考虑这种情况。由于图素大小在每个级联上都不一样,就意味着我们需要向GPU提交更多的级联数据。向 Shadow 添加 vector4 数组,同时缓存 shader 常量名字,然后像其他常量一样进行提交
static int cascadeDataId = Shader.PropertyToID("_CascadeData");
static Vector4[] cascadeData = new Vector4[maxCascadeCount];
...
void RenderDirectionalShadows()
{...// 向GPU上传数据// 设置阴影淡出参数float f = 1 - settings.directional.cascadeFade;buffer.SetGlobalVector(shadowDistanceFadeId, new Vector4(1.0f/settings.maxDistance, 1.0f/settings.distanceFade, 1/(1-f*f), .0f));// 设置级联数据buffer.SetGlobalVectorArray(cascadeDataId, cascadeData);...
}
把级联半径的平方的倒数,存储到 x 分量中,以避免在 shader 中执行除法。定义一个新的函数 SetCascadeData 来完成该逻辑,同时把存储 culling sphere 的逻辑也放到该函数,然后替换调用该方法:
void RenderDirectionalShadows(int index, int split, int tileSize)
{...for (int i = 0; i < cascadeCount; i++){...// 设置级联球体的中心和半径,所有光源都是一样的,因此只需要在第一个级联中设置if (index == 0){SetCascadeData(i, splitData.cullingSphere, tileSize);}}
}// cullingSphere.x, y, z 是球心坐标, w 是半径
void SetCascadeData(int index, Vector4 cullingSphere, float tileSize)
{cascadeData[index].x = 1f/cullingSphere.w; // 半径的倒数// 我们在shader中要比较距离的平方和半径的平方,因此这里存储半径的平方cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[index] = cullingSphere;
}
修改 shadow.hlsl,添加 cascade data 常量
CBUFFER_START(_Shadows)
...
float4 _CascadeData[MAX_CASCADE_COUNT]; // 级联数据,每个级联一个数据]
CBUFFER_END
使用预计算好的倒数:
ShadowData GetShadowData(Surface surfaceWS)
{...if (i == _CascadeCount - 1){shadowData.strength *= FadedShadowStrength(distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z);}break;...
}
4.3 法线偏移
不正确的自阴影是因为投影的深度图素覆盖了超过一个像素,导致投影体超出。因此如果能一定程度上收缩投影,就应该能解决该问题。然而,收缩投影会让阴影变小,从而导致阴影上出现”洞“。
还可以反过来,采样阴影时,放大像素。然后在像素稍微远一点的地方进行采样,远到能避免错误的自阴影。这会调整一点阴影的位置,可能会导致阴影边缘无法对齐,出现不真实的阴影,但是这种效果远没有 peter-panning 那么明显。
可以通过在像素法线方向上稍微移动像素来进行阴影采样。如果只考虑一个维度,那么世界空间中一个图素大小的偏移就够了。在 SetCascadeData 中,用 culling sphere 的直径 除以tile size来得到图素尺寸,由于图素是方形的,我们再对其乘以 1.4142136f 得到对角线长度,并存储到 cascade data 的 y 分量中:
// cullingSphere.x, y, z 是球心坐标, w 是半径void SetCascadeData(int index, Vector4 cullingSphere, float tileSize){cascadeData[index].x = 1f/cullingSphere.w; // 半径的倒数float texelSize = cullingSphere.w * 2.0f / tileSize; // 每个图素的大小cascadeData[index].y = texelSize * 1.4142136f; // 每个图素的对角线长度// 我们在shader中要比较距离的平方和半径的平方,因此这里存储半径的平方cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[index] = cullingSphere;}
在 shadow.hlsl 中,为 GetDirShadowAttenuation 增加一个 ShadowData 参数,根据像素法线和图素对角线长度,计算并应用偏移
float GetDirShadowAttenuation(DirShadowData shadowData, ShadowData global, Surface surfaceWS)
{if(shadowData.strength <= 0.0f)return 1.0f;// 根据像素法线和图素对角线长度,计算偏移float3 normalBias = surfaceWS.normal * _CascadeData[global.cascadeIndex].y;float3 positionSTS = mul(_DirShadowMatrices[shadowData.tileIndex], float4(surfaceWS.position+normalBias,1.0)).xyz;float shadow = SampleDirShadowAtlas(positionSTS);return lerp(1,shadow, shadowData.strength);
}
在 Light.hlsl 中调用该方法的地方,为其添加参数
Light GetDirectionalLight(int index, Surface surfaceWS, ShadowData shadowData)
{Light light;light.color = _DirLightColors[index].rgb;light.direction = normalize(_DirLightDirections[index].xyz);DirShadowData dirShadowData = GetDirectionalLightShadowData(index, shadowData);light.attenuation = GetDirShadowAttenuation(dirShadowData, shadowData, surfaceWS);// 通过直接返回级联索引,现实级联阴影的覆盖情况//light.attenuation = shadowData.cascadeIndex * 0.25f;return light;
}
效果还不错:
4.4 配置偏移
法线偏移解决了毛刺问题,而且没有引入新的问题,但该方案也不能解决所有的问题。例如,在地板上有很多不该存在的阴影线。这不是自阴影,而是阴影穿过墙影响到了地板。
增加一个较小的 slope-scale bias 可以解决该问题,但是没有一个完美的值解决所有情况,因此需要为每个光源配置该参数,根据实际情况进行调整。我们将直接利用 Light 组件上的参数,并在调用 ReserveDirectionalShadows 时,将该参数保存到 ShadowDirectionalLight 结构体中:
public Vector2 ReserveDirectionalShadows(Light light, int visibleLightIndex)
{if(directionalLightCount < maxdirectionalLightCount &&light.shadows != LightShadows.None &&light.shadowStrength > 0f &&cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)){directionalLights[directionalLightCount] = new ShadowedDirectionalLight(){visibleLightIndex = visibleLightIndex,// 这里slopeScaleBias = light.shadowBias};return new Vector2(light.shadowStrength, settings.directional.cascadeCount * directionalLightCount++);}return Vector2.zero;
}
然后在渲染阴影时,应用该参数:
void RenderDirectionalShadows(int index, int split, int tileSize)
{ShadowedDirectionalLight light = directionalLights[index]; ...// 渲染每级 cascade 的阴影for (int i = 0; i < cascadeCount; i++){...buffer.SetGlobalDepthBias(0, light.slopeScaleBias);ExecuteBuffer();context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0, 0);...}
}
Light 组件同样已经提供了 normal bias 参数来调节法线偏移。修改 ReserveDirectionalShadows 返回一个 Vector3,并将该参数存储到 z 分量中。
public Vector3 ReserveDirectionalShadows(Light light, int visibleLightIndex)
{if(directionalLightCount < maxdirectionalLightCount &&light.shadows != LightShadows.None &&light.shadowStrength > 0f &&cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)){directionalLights[directionalLightCount] = new ShadowedDirectionalLight(){visibleLightIndex = visibleLightIndex,slopeScaleBias = light.shadowBias};return new Vector3(light.shadowStrength, settings.directional.cascadeCount * directionalLightCount++,light.shadowNormalBias // 这里是新加的);}return Vector3.zero;
}
在 shadow.hlsl 中,为方向光阴影结构体增加对应的数据成员,并在计算阴影衰减时,应用:
struct DirShadowData
{float strength;int tileIndex;float normalBias; // 法线偏移,用于阴影偏移
};
...
float GetDirShadowAttenuation(DirShadowData shadowData, ShadowData global, Surface surfaceWS)
{if(shadowData.strength <= 0.0f)return 1.0f;// 根据像素法线和图素对角线长度,计算偏移float3 normalBias = surfaceWS.normal * shadowData.normalBias * _CascadeData[global.cascadeIndex].y;float3 positionSTS = mul(_DirShadowMatrices[shadowData.tileIndex], float4(surfaceWS.position+normalBias,1.0)).xyz;float shadow = SampleDirShadowAtlas(positionSTS);return lerp(1,shadow, shadowData.strength);
}
在 Light.hlsl 中,获取方向光阴影数据时,填充该 normal bias 成员:
DirShadowData GetDirectionalLightShadowData(int index, ShadowData shadowData)
{DirShadowData dirShadowData;// 应用级联阴影强度默认为1,像素超出级联范围时强度为0dirShadowData.strength = _DirLightShadowData[index].x * shadowData.strength;// y 是该光源的第一个 tile 的索引, + shadowData.cascadeIndex 表示该光源的第几个级联阴影 tiledirShadowData.tileIndex = _DirLightShadowData[index].y + shadowData.cascadeIndex;dirShadowData.normalBias = _DirLightShadowData[index].z; // 法线偏移return dirShadowData;
}
现在,可以通过拉杆调节每个光源的偏移了。slop-scale bias = 0, normal bias = 1 通常作为默认值。如果要增加第一个的值,那么就要降低第二个的值。但是要注意的是,这两个参数的含义有很大的不同:一个是裁剪空间偏移,一个是世界空间缩放法线的偏移,因此当创建了一个光源,有明显的阴影脱离现象时,可以调节这两个参数来解决。
4.5 阴影摊平 Shadow Pancaking
Unity 中应用 shadow pancaking 可能导致潜在的阴影问题。其背后的原理是在渲染方向光阴影时,会将 near plane 尽可能的向前移(压平)。这提高了深度精度,但同时也意味着那些不在摄像机视野范围内的阴影投射体可能会出现在阴影视口的近平面之后,从而导致它们在不该被裁剪的情况下被裁剪掉了(三角形裁剪,部分三角形被裁剪掉了,因此阴影出现了一些洞)。
上图中的阴影,由一个不再摄像机视口内的长方体投射,其顶端部分在阴影摄像机的 near plane 的后面,因此被裁剪掉了,导致这些三角形的阴影没有被渲染,出现了“洞”。
对这些三角形,可以通过在 ShadowCasterPassVertex 中,将顶点钳制到 near plane 来解决,高效的“压平”在近平面上。通过选择裁剪空间中的 Z 和 W 中较大的坐标,如果定义了 UNITY_REVERSED_Z
那么取最小的值。通过对 W 乘以 UNITY_NEAR_CLIP_VALUE 确保其符号正确。
Varyings ShadowCasterPassVertex(Attributes input)
{Varyings output;UNITY_SETUP_INSTANCE_ID(input);UNITY_TRANSFER_INSTANCE_ID(input, output);float3 positionWS = TransformObjectToWorld(input.positionOS);output.positionCS = TransformWorldToHClip(positionWS);#if UNITY_REVERSED_Zoutput.positionCS.z = min(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);#elseoutput.positionCS.z = max(output.positionCS.z, output.positionCS.w * UNITY_NEAR_CLIP_VALUE);#endiffloat4 baseMapST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);output.uv = input.uv * baseMapST.xy + baseMapST.zw;return output;
}
如下图,问题得到解决
对于在近裁剪面两边的投影对象,上面的方法可以解决。但对于那些长距离穿过近裁剪面(两端距离近裁剪面都很远)的投影对象,只有部分顶点投影,因此阴影看起来产生了变形。对于小三角形来说,这种情况可能并不明显,但对于大三角形而言,它们可能会发生很大的变形,使其弯曲,并且常常会导致其嵌入表面之中。
上图是一个很长的长方体,投射的阴影在中间断开了。
可以通过将近平面向后移动一点来缓解该问题。Light 组件上提供了 Near Plane 滑杆来设置该参数。在 ShadowDirectionalLight 中定义成员来存储该参数
public Vector3 ReserveDirectionalShadows(Light light, int visibleLightIndex)
{if(directionalLightCount < maxdirectionalLightCount &&light.shadows != LightShadows.None &&light.shadowStrength > 0f &&cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b)){directionalLights[directionalLightCount] = new ShadowedDirectionalLight(){visibleLightIndex = visibleLightIndex,slopeScaleBias = light.shadowBias,nearPlaneOffset = light.shadowNearPlane // 新加的};return new Vector3(light.shadowStrength, settings.directional.cascadeCount * directionalLightCount++,light.shadowNormalBias);}return Vector3.zero;
}
将该参数传递给 ComputeDirectionalShadowMatricesAndCullingPrimitives
接口,就可以获得偏移近裁剪面的阴影矩阵:
void RenderDirectionalShadows(int index, int split, int tileSize)
{...// 渲染每级 cascade 的阴影for (int i = 0; i < cascadeCount; i++){// 计算级联阴影矩阵和剔除数据cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount, cascadeRatios, tileSize, light.nearPlaneOffset, // 应用近裁剪面偏移参数out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);...}
}
通过将近裁剪面偏移了 3.16 我们解决了上面的图中的问题:
4.6 PCF Filtering
到目前为止通过为每个像素采样一次阴影图,实现了“硬”阴影。shadow compare sampler 使用特殊形式的双线性插值,在插值前先比较深度。这也被称作 percentage closer filtering 简称 PCF,由于是涉及到了4个像素因此是一个 2x2 PCF filter。
不仅如此,我们还可以使用更大的 filter,使阴影边缘更加柔和,更少的锯齿。我们要加入 2x2 3x3 5x5 7x7 的 filtering。我们不使用 Light 组件为每个光源定义的 soft shadow mode 参数,而是所有方向光使用同一个配置。向 ShadowSettings 中添加 FilterMode 成员,并设置默认值为 2x2:
public enum FilterMode
{PCF2x2,PCF3x3,PCF5x5,PCF7x7
}[System.Serializable]
public struct Directional
{public MapSize atlasSize;public FilterMode filterMode;...
}public Directional directional = new Directional { atlasSize = MapSize._1024, filterMode = FilterMode.PCF2x2, // 默认值...
};
针对不同的 filter mode,要定义不同的 shader 变体。向 Shadow.cs 中添加变体 keyword 的字符串,并根据配置设置 keywords:
...
static string[] dirtionalFilterKeywords = { "_DIRECTIONAL_PCF3", "_DIRECTIONAL_PCF5", "_DIRECTIONAL_PCF7" };
...
void SetKeywords()
{int enabledIndex = (int)settings.directional.filterMode - 1; // PCF2x2, PCF3x3, PCF5x5, PCF7x7for(int i = 0; i < dirtionalFilterKeywords.Length; i++){if (i == enabledIndex)buffer.EnableShaderKeyword(dirtionalFilterKeywords[i]);elsebuffer.DisableShaderKeyword(dirtionalFilterKeywords[i]);}
}
...
void RenderDirectionalShadows()
{...// 设置阴影过滤器的关键字SetKeywords();buffer.EndSample(bufferName);ExecuteBuffer();
}
更大的 filter 需要采样多次,shader 中需要知道阴影图的大小和图素的大小来完成多次采样。在 shadow.cs 中定义变量缓存常量ID,并将该参数提交到GPU:
void RenderDirectionalShadows(){...// 设置阴影过滤器的关键字SetKeywords();// 设置阴影贴图尺寸buffer.SetGlobalVector(shadowAtlasSizeId, new Vector4(atlasSize, 1.0f/atlasSize));...
}
在 shadow.hlsl 中声明常量:
CBUFFER_START(_Shadows)
...
float4 _ShadowAtlasSize; // 阴影图大小,用于计算采样坐标 x = atlasSize, y = 1/atlasSize
CBUFFER_END
为 Lit.shader 的 CustomLit pass 声明变体,单个下划线表示没有设置 keyword,使用默认的 2x2 filter:
...
HLSLPROGRAM
#pragma target 3.5
#pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7
...
我们使用 Core RP Library 中,在 Shadow/ShadowSamplingTent 中定义的函数,因此在 shadow.hlsl 中包含该文件。如果定义了 3x3 pcf 则需要4次 filter sample,并配置函数 SampleShadow_ComputeSamples_Tent_3x3
。只需要4次采样是因为每次都是 2x2 filter,在每个方向上偏移半个像素,采样4次,恰好可以覆盖3x3图素,且中心比边缘权重要大。
如上图,当需要为蓝色像素执行 3x3 pcf 时,需要在两个方向上偏移半个像素,得到4个绿色的坐标,基于这4个坐标执行 2x2 pcf ,正好覆盖 3x3 的图素。
类似的,5x5 pcf 需要9次 2x2 pcf,7x7 需要16次。
修改 shadow.hlsl:
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Shadow/ShadowSamplingTent.hlsl"#if defined(_DIRECTIONAL_PCF3)#define DIRECTIONAL_FILTER_SAMPLES 4#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_3x3
#elif defined(_DIRECTIONAL_PCF5)#define DIRECTIONAL_FILTER_SAMPLES 9#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_5x5
#elif defined(_DIRECTIONAL_PCF7)#define DIRECTIONAL_FILTER_SAMPLES 16#define DIRECTIONAL_FILTER_SETUP SampleShadow_ComputeSamples_Tent_7x7
#endif
并定义新的函数,当未定义 DIRECTIONAL_FILTER_SAMPLES 时,直接返回 SampleDirShadowAtlas 的结果。否则执行多次采样:
float FilterDirectionalShadow(float3 positionSTS)
{
#if defined(DIRECTIONAL_FILTER_SETUP)float weights[DIRECTIONAL_FILTER_SAMPLES];float2 positions[DIRECTIONAL_FILTER_SAMPLES];float4 size = _ShadowAtlasSize.yyxx;DIRECTIONAL_FILTER_SETUP(size, positionSTS.xy, weights, positions);float shadow = 0.0f;for(int i = 0; i < DIRECTIONAL_FILTER_SAMPLES; ++i){// 采样阴影图,并累加权重shadow += weights[i] * SampleDirShadowAtlas(float3(positions[i].xy, positionSTS.z));}return shadow;
#else// 如果没有定义过滤器,则直接采样阴影图,就是 2x2 的采样return SampleDirShadowAtlas(positionSTS);
#endif
}float GetDirShadowAttenuation(DirShadowData shadowData, ShadowData global, Surface surfaceWS)
{if(shadowData.strength <= 0.0f)return 1.0f;// 根据像素法线和图素对角线长度,计算偏移float3 normalBias = surfaceWS.normal * shadowData.normalBias * _CascadeData[global.cascadeIndex].y;float3 positionSTS = mul(_DirShadowMatrices[shadowData.tileIndex], float4(surfaceWS.position+normalBias,1.0)).xyz;float shadow = FilterDirectionalShadow(positionSTS);return lerp(1,shadow, shadowData.strength);
}
上图为 PCF 2x2, 3x3, 5x5, and 7x7。可以发现,随着 PCF 等级提升,阴影确实变得平滑了,但是毛刺又出来了。还是通过调整 normal bias 来解决。我们可以通过在 SetCascadeData 函数中,用 texel size 乘以 1+ filter mode 来自动完成调整。
同时,提升采样范围意味着可能会采样到级联裁剪球的外面。基于 filter size 来降低裁剪球半径来解决该问题。
// cullingSphere.x, y, z 是球心坐标, w 是半径
void SetCascadeData(int index, Vector4 cullingSphere, float tileSize)
{cascadeData[index].x = 1f/cullingSphere.w; // 半径的倒数float texelSize = cullingSphere.w * 2.0f / tileSize; // 每个图素的大小// 根据filterMode,对图素大小进行缩放,避免 PCF等级提升导致的毛刺float filterSize = texelSize * ((float)settings.directional.filterMode+1.0f); cascadeData[index].y = filterSize * 1.4142136f; // 每个图素的对角线长度cullingSphere.w -= filterSize; // 减去过滤器大小,避免采样到裁剪球体外的像素// 我们在shader中要比较距离的平方和半径的平方,因此这里存储半径的平方cullingSphere.w *= cullingSphere.w;cascadeCullingSpheres[index] = cullingSphere;
}
阴影毛刺又得到了解决,但是增大 filter size 会加剧了应用法线偏差的缺陷,使之前看到的墙面阴影渗透问题变得更糟。为了缓解,需要使用 slope-scale bias 或增大图集的大小。
4.7 Blending Cascades 混合级联阴影
软阴影看起来好多了,但是在级联之间的切换时还是很明显。可以看到中间球体阴影的“撕裂”。
我们可以增加一个级联切换区间,在该区间内混合两个级联,这样可以明显改善该问题。
在Shadow.hlsl 中为 ShadowData 增加成员 cascadeBlend,用来在相邻的级联之间插值。
struct ShadowData
{int cascadeIndex; // 级联索引,0-3float cascadeBlend; // 级联混合,用于在级联之间平滑过渡float strength; // 阴影强度,0表示没有阴影,1表示完全有阴影
};
在函数 GetShadowData 中计算其值。
首先将其初始化为 1,表示完全在一个级联内。在循环中,计算淡出的值,如果是最后一级级联,则不需要级联混合,否则记录下该级联淡出的值,用于在计算阴影衰减时,跟下一级级联阴影插值混合。
// 获取方向光阴影数据
// 为表面选择一个阴影级联
ShadowData GetShadowData(Surface surfaceWS)
{ShadowData shadowData;// 最大阴影距离的淡出// 计算阴影强度,如果距离大于阴影距离,则强度为 0,否则默认为 1.0shadowData.strength = FadedShadowStrength(surfaceWS.depth, _ShadowDistanceFade.x, _ShadowDistanceFade.y);shadowData.cascadeBlend = 1.0f; // 级联混合,默认值为 1.0,表示完全在一个级联内// 级联阴影的淡出int i;for (i = 0; i < _CascadeCount; ++i){float4 sphere = _CascadeCullingSpheres[i];float distanceSqr = DistanceSquared(surfaceWS.position, sphere.xyz);if (distanceSqr < sphere.w){float fade = FadedShadowStrength(distanceSqr, _CascadeData[i].x, _ShadowDistanceFade.z);// 最后一级级联,直接应用if (i == _CascadeCount - 1)shadowData.strength *= fade;// 中间的级联,如果计算的 fade 小于 1.0f,则表示需要级联混合elseshadowData.cascadeBlend = fade;break;}}shadowData.cascadeIndex = i;if (i == _CascadeCount)shadowData.strength = 0.0f; // 如果没有找到合适的级联,则强度为 0,表示没有阴影return shadowData;
}
有了级联混合参数,就可以额外在下一级级联阴影中采样,并跟当前级联阴影进行插值混合了
float GetDirShadowAttenuation(DirShadowData shadowData, ShadowData global, Surface surfaceWS)
{if(shadowData.strength <= 0.0f)return 1.0f;// 根据像素法线和图素对角线长度,计算偏移float3 normalBias = surfaceWS.normal * shadowData.normalBias * _CascadeData[global.cascadeIndex].y;float3 positionSTS = mul(_DirShadowMatrices[shadowData.tileIndex], float4(surfaceWS.position+normalBias,1.0)).xyz;float shadow = FilterDirectionalShadow(positionSTS);// 如果有级联混合,则需要跟下一级级联进行混合if(global.cascadeBlend < 1.0f){normalBias = surfaceWS.normal * shadowData.normalBias * _CascadeData[global.cascadeIndex+1].y;positionSTS = mul(_DirShadowMatrices[shadowData.tileIndex+1], float4(surfaceWS.position + normalBias, 1.0)).xyz;float nextShadow = FilterDirectionalShadow(positionSTS);shadow = lerp(shadow, nextShadow, global.cascadeBlend);}return lerp(1,shadow, shadowData.strength);
}
可以看到中间球体阴影的混合效果。嗯,好像也不太理想
4.8 Dithered Transition 抖动的过渡
混合相邻级联阴影确实可以提升效果,但是代价是在混合范围内多执行了一次阴影衰减计算,当PCF级别高时,代价就更高了。还有一种方法可以依然只计算一次,那就是 Dither 模式。
为方向光定义级联混合模式,并为其指定默认值 Hard
public enum CascadeBlendMode
{Hard, Soft, Dither
}
public Directional directional = new Directional { ...cascadeFade = 0.1f,casdeBlendMode = CascadeBlendMode.Hard
};
级联混合模式定义为 shader 变体,因此需要定义开启 keyword 的逻辑。我们修改 SetKeywords 适用于通用情况。
// 接受关键字数组和相关的值,设置 shader 中的关键字
void SetKeywords(string[] keywords, int value)
{int enabledIndex = (int)value - 1;for(int i = 0; i < keywords.Length; i++){if (i == enabledIndex)buffer.EnableShaderKeyword(keywords[i]);elsebuffer.DisableShaderKeyword(keywords[i]);}
}
设置关键字
void RenderDirectionalShadows()
{...// 设置阴影过滤器的关键字SetKeywords(dirtionalFilterKeywords, (int)settings.directional.filterMode);// 设置级联混合模式的关键字SetKeywords(cascadeBlendKeywords, (int)settings.directional.casdeBlendMode);...
}
每种混合模式都是一个 shader 变体,因此在 Lit.shader 中的 CustomList Pass 中定义 multi-compole 指令
#pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7
#pragam multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER
#pragma multi_compile_instancing
为 Surface 定义 dither 成员,并在像素着色器中生成 dither。生成 dither 有多种方式,最简单的是直接调用 Unity 在 Core RP Library 中定义的 InterleavedGradientNoise 方法,根据给定的裁剪空间的 XY 位置来生成一个旋转的平铺的 dither 图案。方法还接受第二个参数来生成动画效果,我们不需要,所以我们给定为 0。
float4 LitPassFragment(Varyings input) : SV_TARGET
{...surface.smoothness = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);...
}
在 GetShadowData 中,处理变体宏:
ShadowData GetShadowData(Surface surfaceWS)
{...if (i == _CascadeCount)shadowData.strength = 0.0f; // 如果没有找到合适的级联,则强度为 0,表示没有阴影
#if defined(_CASCADE_BLEND_DITHER)else if(shadowData.cascadeBlend < surfaceWS.dither)i+=1; // 如果级联混合小于抖动值,则直接使用下一个级联
#endif
#if !defined(_CASCADE_BLEND_SOFT)shadowData.cascadeBlend = 1.0f; // 如果没有定义级联混合,则强制为 1.0#endif shadowData.cascadeIndex = i;return shadowData;
}
我的项目 Dither 效果不明显,还是贴原教程的图吧
4.9 Culling Bias 裁剪偏移
Cascaded Shadow map 的缺点是每个投影对象每盏灯都要渲染多次。如果能确保一个投影对象在低一级的 cascade 中被渲染了,就可以在高级的 cascade 中裁剪掉。Unity 通过SplitData.shadowCascadeBlendCullingFactor 的参数来实现这一点。
该值作为系数,用来调节上一个 cascade radius 来执行裁剪。Unity 在裁剪时是十分保守的,但是我们可以基于 cascade fade ratio 调整该值,并稍微大一点,以确保在级联过渡区的投影对象不会被裁剪掉。因此我们用 0.8 减去 fade range,同时确保该值不小于 0。如果看到 cascade 过渡区周围出现了洞,那么就需要进一步降低该值。
void RenderDirectionalShadows(int index, int split, int tileSize)
{...// 看这里float cullingFactor = Mathf.Max(0f, 0.8f - settings.directional.cascadeFade);// 渲染每级 cascade 的阴影for (int i = 0; i < cascadeCount; i++){// 计算级联阴影矩阵和剔除数据cullingResults.ComputeDirectionalShadowMatricesAndCullingPrimitives(light.visibleLightIndex, i, cascadeCount, cascadeRatios, tileSize, light.nearPlaneOffset,out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);// 看这里splitData.shadowCascadeBlendCullingFactor = cullingFactor;shadowSettings.splitData = splitData;...
}
这个渲染效果不明显,还是用原作者提供的图:
图一:Culling Bias = 0
图二:Culling bias = 1
5 透明
现在 Clip, fade, transparent 材质都可以像不透明材质一样接受阴影,但是现在只有 clip 材质可以正确投影。透明物体以不透明方式进行投影。
5.1 Shaodw Modes 阴影模式
处理透明阴影没什么太好的办法,因为投影是渲染到深度图上,根据深度判断,要么在阴影里,要么不在。只能通过阴影采样计算衰减时做一些处理:on, fully solid, clipped, dithered, off。
添加 _Shadow shader 属性,用 KeywordEnum 来为材质面板创建下拉菜单,同时默认是 on
[Toggle(_CLIPPING)]_Clip("Alpha Clipping", Float) = 0.0
[KeywordEnum(On, Clip, Dither, Off)]_Shadow("Shadows", Float) = 0 // 这里
在阴影 pass 中,添加响应的 shader feature,替换 _CLIPPING feature,只需要3个变体:用 no 表示 on 和 off,_SHADOW_CLIP, _SHADOW_DITHER
在 CustomShaderGUI 中,定义对应的枚举,定义属性 setter,并在更新值时设置属性和 keyword
enum ShadowMode
{ On, Clip, Dither, Off }ShadowMode Shadows { set{if(SetProperty("_Shadows", (float)value)){SetKeyword("_SHADOWS_CLIP", value == ShadowMode.Clip);SetKeyword("_SHADOWS_DITHER", value == ShadowMode.Dither);}}
}
5.2 Clipped And Dithered Shadows
在阴影渲染的像素着色器中,判断如果是 _SHADOW_CLIP ,依然执行 _CLIPPING 的逻辑
如果是 Dithered,则生成 dither 值,用该值执行 clip
#if defined(_SHADOWS_CLIP)clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#elif defined(_SHADOWS_DITHER)float dither = InterleavedGradientNoise(input.positionCS.xy, 0);clip(base.a - dither);
#endif
下图是 clip 和 dithered 阴影
Dithered shadow 加一个较大的 PCF 可以使其看起来更像半透明的阴影。
5.3 No Shadows
我们在上面定义了4种阴影模式,其中 off 模式还没有处理。对于 off 模式,我们通过关闭阴影 pass 来实现。
如果选中的材质(可能是多选),它们都有 _Shadows 属性,并且值都一样,则通过调用 Mateiral.SetShaderPassEnabled 方法开关闭。我们把阴影开启/关闭定义成一个专门的函数:
void SetShadowCasterPass()
{MaterialProperty shadows = FindProperty("_Shadows", properties, false);if (shadows == null || shadows.hasMixedValue)return;bool enabled = shadows.floatValue < (float)ShadowMode.Off;foreach (Material m in materials){m.SetShaderPassEnabled("ShadowCaster", enabled);}
}
在 OnGUI 函数中开始的位置调用 EditorUI.BeginChangeCheck(),结束时调用 EndChangeCheck(),如果过程中参数有改变,则返回 true,我们就可以调用 开启/关闭 阴影的函数了
public override void OnGUI(MaterialEditor matEditor, MaterialProperty[] props)
{EditorGUI.BeginChangeCheck();...if(EditorGUI.EndChangeCheck()){// 如果有修改,则更新阴影通道SetShadowCasterPass();}
}
下图中,我们把右侧的球的阴影模式设置为 off ,因此不渲染阴影了
5.4 Unlit Shadow Casters
让无光照对象投射阴影,只需要把 lit.shader 中的阴影 pass 复制到 unlit.shader 中即可。同时不要忘记拷贝 Shader Properties 中阴影模式。可以看到效果:
5.5 Receiving Shadows 接受阴影
我们可能希望某些材质不接受阴影,因此需要为 CustomLit Pass 增加一个 shader feature: _RECEIVE_SHADOWS,在 Shader Properties 中用 Toggle 来编辑
然后在计算阴影衰减时,直接返回 1,表示不受阴影影响
float GetDirShadowAttenuation(DirShadowData shadowData, ShadowData global, Surface surfaceWS)
{
#if !defined(_RECEIVE_SHADOWS)return 1.0f;
#endif...
}
下图是接受阴影
下图是不接受阴影的效果。因为我们地面用了跟球一个材质,因此地面也不接受阴影了。