Custom SRP - Point And Spot Shadows
https://catlikecoding.com/unity/tutorials/custom-srp/point-and-spot-shadows/
- 混合 Point / Spot 的实时阴影和烘焙阴影
- 增加第二个 shadow atlas
- 渲染和采样透视投影的阴影
- 使用自定义 cube maps
1 Spot Light Shadows
我们将会使用跟方向光类似的方式,实现 spot 实时阴影,当然 spot 没有 cascades,以及一些其它差异.
1.1 Shadow Mixing
第一步是实现混合阴影的逻辑流程.在shadow.hlsl 中
// 获取 Point/Spot 的实时阴影,还没有渲染阴影,所以先返回 1
float GetOtherShadow(OtherShadowData other, ShadowData global, Surface surfaceWS)
{return 1.0f;
}// 获取 point/spot 阴影衰减
float GetOtherShadowAttenuation(OtherShadowData other, ShadowData global, Surface surfaceWS)
{// 材质不接收阴影
#if !defined(_RECEIVE_SHADOWS)return 1.0f;
#endiffloat shadow;// 我们用 strength 的符号,来区别混合/实时阴影// 如果仅有烘焙阴影,则符号为负,仅采样 shadow mask// 可能是因为超出了阴影距离,也可能是在最大阴影裁剪球的外面if(other.strength * global.strength <= 0.0f){shadow = GetBakedShadow(global.shadowMask, abs(other.strength), other.shadowMaskChannel);}// 混合实时和烘焙阴影else{// 获取实时阴影shadow = GetOtherShadow(other, global, surfaceWS);// 混合shadow - MixBakedAndRealtimeShadow(global, shadow, other.strength, other.shadowMaskChannel);}return shadow;
}
我们需要用渲染实时阴影的方向光的数量,同时需要阴影的淡出参数.这些参数之前是在渲染方向光阴影时提交到GPU的,这里需要提出来.因为即使不渲染方向光阴影,渲染其它光源阴影时也是需要的
注意为了跟教程保持一致,我们把变量 directionalLightCount 改成了教程中的 shadowedDirLightCount
public void Render(){...// 级联数量,如果没有方向光阴影,则设置为0buffer.SetGlobalInt(cascadeCountId, shadowedDirLightCount > 0 ? settings.directional.cascadeCount : 0);// 设置阴影淡出参数.方向光和其它光都需要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.EndSample(bufferName);ExecuteBuffer();}
另外,之前我们在获取阴影时,如果找到最后一个裁剪球,则将阴影强度设置为0,表示没有阴影.但是现在在方向光阴影之后,还有其
ShadowData GetShadowData(Surface surfaceWS)
{...// 如果没有找到合适的级联,且只有方向光阴影,则强度为 0,表示没有阴影if (i == _CascadeCount && _CascadeCount > 0)shadowData.strength = 0.0f;
#if defined(_CASCADE_BLEND_DITHER)else if(shadowData.cascadeBlend < surfaceWS.dither)i+=1; // 如果级联混合小于抖动值,则直接使用下一个级联
#endif... return shadowData;
}
它阴影,因此要避免误判,因此在 shadow.hlsl 中
1.2 Other Realtime Shadows
我们将为其它光源使用单独的阴影图集,并单独计数,最大支持16个其它类型光源的阴影.在 shadow.cs 中
// 其它投射阴影的最大光源数量
private const int maxShadowedOtherLightCount = 16;
// 其它投射阴影的光源的数量
private int shadowedOtherLightCount = 0;...
public void Setup(ScriptableRenderContext context, CullingResults cullingResults, ShadowSettings settings)
{this.context = context;this.cullingResults = cullingResults;this.settings = settings;shadowedDirLightCount = 0;shadowedOtherLightCount = 0;// 每次初始化,不使用烘焙阴影useShadowMask = false;
}
所以可能有的光源参与光照,开启阴影,但是最终没有阴影,这依赖于光源在 visibleLights 列表中的顺序,因为我们只是简单的遍历这个列表来收集需要绘制阴影的光源,并在超出最大数量(16)时终止.但是这不影响烘焙阴影.因此需要重构 ReserveOtherShadows 函数
public Vector4 ReserveOtherShadows(Light light, int visibleLightIndex)
{// 检查如果不需要投影,直接返回if(light.shadows == LightShadows.None || light.shadowStrength <= 0f)return new Vector4(0f,0f,0f,-1f);float maskChannel = -1f;LightBakingOutput lightBaking = light.bakingOutput;if (lightBaking.lightmapBakeType == LightmapBakeType.Mixed&& lightBaking.mixedLightingMode == MixedLightingMode.Shadowmask){useShadowMask = true;maskChannel = lightBaking.occlusionMaskChannel;}// 阴影光源到达上限,或者没有拿到阴影裁剪Bounds,则认为只有烘焙阴影,按照约定,将强度设置为负的if(shadowedOtherLightCount >= maxShadowedOtherLightCount || !cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b))return new Vector4(-light.shadowStrength, 0f, 0f, maskChannel);// 否则,将第三个参数设置为投影光源索引,作为阴影的tile索引return new Vector4(light.shadowStrength, shadowedOtherLightCount, 0f, maskChannel);
}
1.3 Two Atlases(Other Shadow Configue)
其它光源的阴影图集是单独的,因此它们的参数也可以单独配置,因此在 ShadowSettings.cs 中
[System.Serializable]
public class ShadowSettings
{...[System.Serializable]public struct Other{public MapSize atlasSize;public FilterMode filter;}public Other other = new Other{atlasSize = MapSize._1024,filter = FilterMode.PCF2x2};
}
在 Lit.shader 增加 multi_compile 指令,定义 Other PCF,并在 shadow.cs 定义对应的 keywords:
/////////////// lit.shader
#pragma multi_compile _ _OTHER_PCF3 _OTHER_PCF5 _OTHER_PCF7////////////////// shadow.cs
// 其它光源的 PCF keywords
static string[] otherFilterKeywords = { "_OTHER_PCF3", "_OTHER_PCF5", "_OTHER_PCF7" };
还需要记录图集和阴影矩阵的 shader 属性ID,并为阴影矩阵定义缓存数组.在 shadow.cs 中
static int otherShadowAtlasId = Shader.PropertyToID("_OtherShadowAtlas");
static int otherShadowMatricesId = Shader.PropertyToID("_OtherShadowMatrices");static Matrix4x4[] otherShadowMatrices = new Matrix4x4[maxShadowedOtherLightCount];
我们之前用一个 Vector xy 向GPU提交了方向光的图集尺寸,这里我们依然用 Vector,但是定义成一个成员变量,并用 zw 提交其它光源图集尺寸
// 图集尺寸
// x,y 方向光图集尺寸及其倒数
// z,w 其它光图集尺寸及其倒数
private Vector4 atlasSizes;public void Render()
{// 渲染方向光阴影if (shadowedDirLightCount > 0)RenderDirectionalShadows();// 如果不创建 _DirectionalShadowAtlas,在 WebGL2.0 会出错:其会绑定默认图,而默认图不是 shadowmap 兼容的格式.// 因此,如果没有阴影方向光,则创建一个1x1像素的 RenderTarget.elsebuffer.GetTemporaryRT(dirShadowAtlasId, 1, 1,32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);// 渲染其它光源阴影if(shadowedOtherLightCount > 0)RenderOtherShadows();// 没有其它光源需要渲染引用,则使用方向光阴影贴图(渲染的阴影图或1x1的图,一定有效,但是 shader 中并不会使用)elsebuffer.SetGlobalTexture(otherShadowAtlasId, dirShadowAtlasId);...// 设置阴影贴图尺寸buffer.SetGlobalVector(shadowAtlasSizeId, atlasSizes);buffer.EndSample(bufferName);ExecuteBuffer();
}void RenderDirectionalShadows()
{...atlasSizes.x = atlasSize;atlasSizes.y = 1.0f / atlasSize;buffer.EndSample(bufferName);ExecuteBuffer();
}
定义 RenderOtherShadows 接口,渲染其它光源阴影,其逻辑与 RenderDirectionalShadows 类似
/// <summary>
/// 渲染其它类型光源的阴影贴图
/// </summary>
void RenderOtherShadows()
{// 计算图集尺寸int atlasSize = (int)settings.other.atlasSize;atlasSizes.z = atlasSize;atlasSizes.w = 1.0f / atlasSize;// 创建一个 RenderTexture 用于存储光的阴影贴图buffer.GetTemporaryRT(otherShadowAtlasId, atlasSize, atlasSize, 32,FilterMode.Bilinear, RenderTextureFormat.Shadowmap);buffer.SetRenderTarget(otherShadowAtlasId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store);buffer.ClearRenderTarget(true, false, Color.clear);buffer.BeginSample(bufferName);ExecuteBuffer();// 计算 tile 划分int tiles = shadowedOtherLightCount;int split = tiles <= 1 ? 1 : tiles <= 4 ? 2 : 4; // 1x1, 2x2, or 4x4 gridint tileSize = atlasSize / split;// 渲染每个光源的阴影for (int i = 0; i < shadowedOtherLightCount; i++){// 渲染 spot 阴影RenderSpotShadows(i, split, tileSize);}// 向GPU上传数据// 设置将坐标从世界空间变换到阴影贴图空间(坐标)到矩阵buffer.SetGlobalMatrixArray(otherShadowMatricesId, otherShadowMatrices);// 设置阴影过滤器的关键字SetKeywords(otherFilterKeywords, (int)settings.other.filter);buffer.EndSample(bufferName);ExecuteBuffer();
}
在阴影渲染主逻辑中,调用 RenderOtherShadows。同时在 Cleanup 中,释放其它光源阴影贴图
public void Render()
{// 渲染方向光阴影if (shadowedDirLightCount > 0)RenderDirectionalShadows();// 如果不创建 _DirectionalShadowAtlas,在 WebGL2.0 会出错:其会绑定默认图,而默认图不是 shadowmap 兼容的格式.// 因此,如果没有阴影方向光,则创建一个1x1像素的 RenderTarget.elsebuffer.GetTemporaryRT(dirShadowAtlasId, 1, 1,32, FilterMode.Bilinear, RenderTextureFormat.Shadowmap);// 渲染其它光源阴影if(shadowedOtherLightCount > 0)RenderOtherShadows();// 没有其它光源需要渲染引用,则使用方向光阴影贴图(渲染的阴影图或1x1的图,一定有效,但是 shader 中并不会使用)elsebuffer.SetGlobalTexture(otherShadowAtlasId, dirShadowAtlasId);...
}public void Cleanup()
{buffer.ReleaseTemporaryRT(dirShadowAtlasId);if(shadowedOtherLightCount > 0)buffer.ReleaseTemporaryRT(otherShadowAtlasId);ExecuteBuffer();
}
1.4 Rendering Spot Shadows
渲染 spot 阴影需要一些光源信息,定义结构体,并在 ReserveOtherShadows 中记录下来
// 其它光源投影阴影的参数的数据结构
struct ShadowedOtherLight
{public int visibleLightIndex;public float slopeScaleBias;public float normalBias;
}
ShadowedOtherLight[] shadowedOtherLights = new ShadowedOtherLight[maxShadowedOtherLightCount];/// <summary>
/// 收集 Point/Spot shadow mask 数据
/// </summary>
/// <param name="light"></param>
/// <param name="visibleLightIndex">光源在 cullingResults.visibleLights 中的索引</param>
/// <returns></returns>
public Vector4 ReserveOtherShadows(Light light, int visibleLightIndex)
{// 检查如果不需要投影,直接返回if(light.shadows == LightShadows.None || light.shadowStrength <= 0f)return new Vector4(0f,0f,0f,-1f);float maskChannel = -1f;LightBakingOutput lightBaking = light.bakingOutput;if (lightBaking.lightmapBakeType == LightmapBakeType.Mixed&& lightBaking.mixedLightingMode == MixedLightingMode.Shadowmask){useShadowMask = true;maskChannel = lightBaking.occlusionMaskChannel;}// 阴影光源到达上限,或者没有拿到阴影裁剪Bounds,则认为只有烘焙阴影,按照约定,将强度设置为负的if(shadowedOtherLightCount >= maxShadowedOtherLightCount || !cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b))return new Vector4(-light.shadowStrength, 0f, 0f, maskChannel);// 记录光源投影参数shadowedOtherLights[shadowedOtherLightCount] = new ShadowedOtherLight(){visibleLightIndex = visibleLightIndex,slopeScaleBias = light.shadowBias,normalBias = light.shadowNormalBias};// 否则,将第三个参数设置为投影光源索引,作为阴影的tile索引return new Vector4(light.shadowStrength, shadowedOtherLightCount++, 0f, maskChannel);
}
ReserveDirectional/OtherShadows 接口中的索引,应该使用在 cullingResults.visibleLights 中的索引,因此给 SetupXXXLight 接口增加 visibleIndex 参数,传递给 ReserveXXXShadows 接口。同时调用Setup 的地方,该参数传入 visibleLights 索引。修改 Lighting.cs 代码:
// 收集方向光数据
private void SetupDirectionalLight(int index, int visibleIndex, ref VisibleLight light)
{...dirLightShadowData[index] = shadows.ReserveDirectionalShadows(light.light, visibleIndex);
}// 收集 Point 光源数据
private void SetupPointLight(int index, int visibleIndex, ref VisibleLight light)
{...otherLightShadowData[index] = shadows.ReserveOtherShadows(light.light, visibleIndex);
}// 收集 Spot 光源数据
private void SetupSpotLight(int index, int visibleIndex, ref VisibleLight light)
{...otherLightShadowData[index] = shadows.ReserveOtherShadows(light.light, visibleIndex);
}public void SetupLights(bool usePerObjectLights)
{...NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;int i = 0;for(i = 0; i < visibleLights.Length; i++){int newIndex = -1;VisibleLight light = visibleLights[i];switch(light.lightType){// 方向光case LightType.Directional:if(dirLightCount < maxDirLightCount)SetupDirectionalLight(dirLightCount++, i, ref light);break;// 点光源case LightType.Point:if(otherLightCount < maxOtherLightCount){newIndex = otherLightCount;SetupPointLight(otherLightCount++, i, ref light);}break;// 聚光灯case LightType.Spot:if(otherLightCount < maxOtherLightCount){newIndex = otherLightCount;SetupSpotLight(otherLightCount++, i, ref light);}break;}....}....
}
实现渲染 Spot 阴影的接口,在shadow.cs 中
/// <summary>
/// 渲染一个 Spot 类型光源的阴影贴图
/// </summary>
/// <param name="index">第几个方向光</param>
/// <param name="split">贴图如何划分 1x1 2x2 4x2</param>
/// <param name="tileSize">每个 tile 的尺寸</param>
void RenderSpotShadows(int index, int split, int tileSize)
{ShadowedOtherLight light = shadowedOtherLights[index];// 设置阴影渲染参数var shadowSettings = new ShadowDrawingSettings(cullingResults,light.visibleLightIndex,BatchCullingProjectionType.Perspective // 其它光源阴影用透视投影);// 计算阴影矩阵和剔除数据cullingResults.ComputeSpotShadowMatricesAndCullingPrimitives(light.visibleLightIndex, out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;// 设置视口,在对应的 tile 上渲染阴影Vector2 offset = SetViewport(index, split, tileSize);// 生成将世界坐标变换到阴影贴图UV坐标的矩阵otherShadowMatrices[index] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix, offset, split);// 设置阴影渲染的投影矩阵buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);buffer.SetGlobalDepthBias(0, light.slopeScaleBias);ExecuteBuffer();// 绘制 spot 阴影context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0, 0);
}
我们的场景里有2个聚光灯,所以atlas拆分成4个,渲染到了下面两个上:
1.5 No Pancaking
shadow pancaking 只有在正交投影下才是正确的,而 spot 是透视投影,因此需要定义一个 shader 常量,来开启/关闭
// 是否开启 pancaking
static int shadowPancakingId = Shader.PropertyToID("_ShadowPancaking");void RenderDirectionalShadows()
{...// 方向光可以开启 pancakingbuffer.SetGlobalFloat(shadowPancakingId, 1f);buffer.BeginSample(bufferName);ExecuteBuffer();...
}void RenderOtherShadows()
{...// 关闭 pancakingbuffer.SetGlobalFloat(shadowPancakingId, 0f);buffer.BeginSample(bufferName);ExecuteBuffer();...
}
在 ShadowCasterPass.hlsl 中,定义常量,并根据该常量判断是否执行 pancaking:
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 (_ShadowPancaking){
#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);
#endif}output.uv = TransformBaseUV(input.uv);return output;
}
1.6 Sampling Spot Shadows
现在完善 Shadow.hlsl 采样 spot shadow
采样阴影需要知道 tileIndex,因此在 shadow.hlsl 完善结构体,并在 Light.hlsl 中填充 tileIndex
////////////////// Shadow.hlsl
// point / spot 光源 shadow 数据
struct OtherShadowData
{float strength;int tileIndex;int shadowMaskChannel;
};////////////////// Light.hlsl
OtherShadowData GetOtherLightShadowData(int index)
{OtherShadowData data;data.strength = _OtherLightShadowData[index].x;data.tileIndex = _OtherLightShadowData[index].y;data.shadowMaskChannel = _OtherLightShadowData[index].w;return data;
}
如下图,我们的两个 spot lights 都投射了阴影
1.7 Normal Bias
上图中可以看到 shadow acne 很严重,由于目前的 normal bias 算法仅适用与方向光,因此没有开启。在透视投影下,阴影图素大小不是固定的,因此 acne 大小也不固定。shadow map 上的图素距离光源越远,覆盖是世界空间就越大,acne也越大。这个变化是线性的,因此可以基于距离来缩放图素的尺寸。
在世界空间中,距离光源1个单位距离上,tileSize 是 。这跟投影矩阵是一致的,因此世界空间中的图素大小 :
这是距离为1的图素尺寸,在 shader 中,需要根据像素到光源的距离,对图素尺寸进行缩放。
准备好数据,并上传到 shader。在 shadow.cs 中:
// other shadow tile 参数
// w: normalBias
static int otherShadowTilesId = Shader.PropertyToID("_OtherShadowTiles");
Vector4[] otherShadowTiles = new Vector4[maxShadowedOtherLightCount];
...void RenderSpotShadows(int index, int split, int tileSize)
{ShadowedOtherLight light = shadowedOtherLights[index];// 设置阴影渲染参数var shadowSettings = new ShadowDrawingSettings(cullingResults,light.visibleLightIndex,BatchCullingProjectionType.Perspective // 其它光源阴影用透视投影);// 计算阴影矩阵和剔除数据cullingResults.ComputeSpotShadowMatricesAndCullingPrimitives(light.visibleLightIndex, out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;// 设置视口,在对应的 tile 上渲染阴影Vector2 offset = SetViewport(index, split, tileSize);// 记录采样 shadow map 时需要的参数// 生成将世界坐标变换到阴影贴图UV坐标的矩阵otherShadowMatrices[index] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix, offset, split);// 每个图素在1单位距离上的大小float texelSize = 2f / (tileSize * projectionMatrix.m00);// 根据filterMode,对图素大小进行缩放,避免 PCF等级提升导致的毛刺float filterSize = texelSize * ((float)settings.other.filter + 1f);// normalBias 乘以每个图素的对角线长度float bias = light.normalBias * filterSize * 1.4142136f;SetOtherTileData(index, bias);otherShadowTiles[index] = new Vector4(tileSize, 1.0f / tileSize, light.normalBias, 0f);// 设置阴影渲染的投影矩阵buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);buffer.SetGlobalDepthBias(0, light.slopeScaleBias);ExecuteBuffer();// 绘制 spot 阴影context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0, 0);
}void SetOtherTileData(int index, float bias)
{otherShadowTiles[index].w = bias;
}
在shader中,接收数据,并根据距离缩放 normal bias
//////////////////////////////////////
// shadow.hlslCBUFFER_START(_Shadows)
...
float4 _OtherShadowTiles[MAX_OTHER_SHADOW_COUNT]; // 其它光源阴影图 tile 信息,用于计算采样坐标
float4 _ShadowDistanceFade; // 阴影距离,排除距离过远的像素,禁止采样阴影
...
CBUFFER_END...// point / spot 光源 shadow 数据
struct OtherShadowData
{float strength; // 阴影强度int tileIndex; // 在 shadow atlas 中的 tile 索引int shadowMaskChannel; // 在 shadow mask map 中的通道索引float3 lightPositionWS; // 光源位置float3 spotDirectionWS; // 聚光灯方向
};...// 获取 Point/Spot 的实时阴影
float GetOtherShadow(OtherShadowData other, ShadowData global, Surface surfaceWS)
{float4 tileData = _OtherShadowTiles[other.tileIndex];// 计算像素到光源的距离float distToLight = dot(other.lightPositionWS, other.spotDirectionWS);// 根据距离计算法线偏移float normalBiasScale = distToLight * tileData.w;// 法线偏移float3 normalBias = surfaceWS.interplotedNormal * normalBiasScale; // 计算采样坐标float4 positionSTS = mul(_OtherShadowMatrices[other.tileIndex], float4(surfaceWS.position + normalBias, 1.0));return FilterOtherShadow(positionSTS.xyz/positionSTS.w);
}////////////////////////////////////
// light.hlsl
// 填充计算 normal bias 需要的光源位置和聚光灯方向OtherShadowData GetOtherLightShadowData(int index)
{OtherShadowData data;data.strength = _OtherLightShadowData[index].x;data.tileIndex = _OtherLightShadowData[index].y;data.shadowMaskChannel = _OtherLightShadowData[index].w;data.lightPositionWS = 0.0; // 这里设置默认值,在 GetOtherLight 函数中赋值data.spotDirectionWS = 0.0;return data;
}Light GetOtherLight(int index, Surface surfaceWS, ShadowData shadowData)
{Light light;light.color = _OtherLightColors[index].rgb;float3 ray = _OtherLightPositions[index].xyz - surfaceWS.position;// 计算光源范围衰减float distSqr = max(dot(ray, ray), 0.000001f);light.direction = normalize(ray);float rangeAttenuation = Square(saturate(1.0 - Square(distSqr*_OtherLightPositions[index].w)));// 计算聚光灯的内外角度衰减 (saturate(da+b))^2 float4 spotAngles = _OtherLightSpotAngles[index];float dotProduct = dot(_OtherLightDirections[index].xyz, light.direction);float spotAttenuation = Square(saturate(dotProduct * spotAngles.x +spotAngles.y));// 获取 shadow maskOtherShadowData otherShadowData = GetOtherLightShadowData(index); // 填充光源位置,聚光灯方向otherShadowData.lightPositionWS = _OtherLightPositions[index].xyz;otherShadowData.spotDirectionWS = _OtherLightDirections[index].xyz;float shadowMaskAttenuation = GetOtherShadowAttenuation(otherShadowData, shadowData, surfaceWS); // 总衰减light.attenuation = shadowMaskAttenuation * spotAttenuation * rangeAttenuation / distSqr;return light;
}
如下图,我们得到了正确的 normal bias 处理后的效果,消除了 acne:
1.8 Clamped Sampling
为了避免采样 shadow map 时,超出 tile 范围,我们需要在计算完采样UV坐标后,进行 clamp,限制在 tile 内,因此我们需要计算 tile 的 uv 偏移和尺寸(都是0-1)。
void RenderSpotShadows(int index, int split, int tileSize)
{...// normalBias 乘以每个图素的对角线长度float bias = light.normalBias * filterSize * 1.4142136f;SetOtherTileData(index, offset, 1f / split, bias);...
}// index: 第几个 tile
// offset: tile 在图集中的偏移UV
// scale: tile 在图集中的UV尺寸
// bias: 单位距离时的图素尺寸
void SetOtherTileData(int index, Vector2 offset, float scale, float bias)
{// 边缘向内收缩半个像素,避免采样到相邻 tile 的像素float border = atlasSizes.w * 0.5f;Vector4 data;data.x = offset.x * scale + border;data.y = offset.y * scale + border;data.z = scale - border * 2f;data.w = bias; // 图素大小otherShadowTiles[index] = data;
}
...
然后在 shadow.hlsl 中采样 shadow map 时,执行钳制逻辑
// 采样其它光源阴影图
// bounds: tile 在 atlas 中的边界
float SampleOtherShadowAtlas(float3 positionSTS, float3 bounds)
{positionSTS.xy = clamp(positionSTS.xy, bounds.xy, bounds.xy + bounds.z);return SAMPLE_TEXTURE2D_SHADOW(_OtherShadowAtlas, SHADOW_SAMPLER, positionSTS);
}// 采样并过滤其它光源阴影图
float FilterOtherShadow(float3 positionSTS, float3 bounds)
{
#if defined(OTHER_FILTER_SETUP)real weights[OTHER_FILTER_SAMPLES];real2 positions[OTHER_FILTER_SAMPLES];float4 size = _ShadowAtlasSize.wwzz;OTHER_FILTER_SETUP(size, positionSTS.xy, weights, positions);float shadow = 0.0f;for(int i = 0; i < OTHER_FILTER_SAMPLES; int++)shadow += weights[i] * SampleOtherShadowAtlas(float3(positions[i].xy, positionSTS.z), bounds);return shadow;
#elsereturn SampleOtherShadowAtlas(positionSTS, bounds);
#endif
}// 获取 Point/Spot 的实时阴影
float GetOtherShadow(OtherShadowData other, ShadowData global, Surface surfaceWS)
{float4 tileData = _OtherShadowTiles[other.tileIndex];// 计算像素到光源的距离float distToLight = dot(other.lightPositionWS, other.spotDirectionWS);// 根据距离计算法线偏移float normalBiasScale = distToLight * tileData.w;// 法线偏移float3 normalBias = surfaceWS.interplotedNormal * normalBiasScale; // 计算采样坐标float4 positionSTS = mul(_OtherShadowMatrices[other.tileIndex], float4(surfaceWS.position + normalBias, 1.0));return FilterOtherShadow(positionSTS.xyz/positionSTS.w, tileData.xyz);
}
2 Point Light Shadows
Point Light 阴影同 Spot Light 一样,只不过 Point 需要渲染 6 个方向上的 shadow map 到一个 cube map 上,因此需要 6 个 tiles。因为我们为其它光源定义了最大 tile 数量是 16,因此最多同时支持2个点光源阴影(需要2*6=12个tile),剩下4个还可以支持4个聚光灯。
点光源实际上被当作6个聚光灯处理。
2.1 Six Tiles for One Point
由于与 spot light 有所不同,因此在 C# 和 shader 里,都需要知道该光源是否是 point,并且记录/引用正确的 tileIndex
在 shadow.cs 中
// 其它光源投影阴影的参数的数据结构
struct ShadowedOtherLight
{...public bool isPoint; // 是否是点光源
}...public Vector4 ReserveOtherShadows(Light light, int visibleLightIndex)
{...bool isPoint = light.type == LightType.Point;int newLightCount = isPoint ? 6 : 1; // 点光源需要渲染6次// 阴影光源到达上限,或者没有拿到阴影裁剪Bounds,则认为只有烘焙阴影,按照约定,将强度设置为负的if (shadowedOtherLightCount + newLightCount > maxShadowedOtherLightCount || !cullingResults.GetShadowCasterBounds(visibleLightIndex, out Bounds b))return new Vector4(-light.shadowStrength, 0f, 0f, maskChannel);// 记录光源投影参数shadowedOtherLights[shadowedOtherLightCount] = new ShadowedOtherLight(){visibleLightIndex = visibleLightIndex,slopeScaleBias = light.shadowBias,normalBias = light.shadowNormalBias,isPoint = isPoint};// 否则,将第三个参数设置为投影光源索引,作为阴影的tile索引Vector4 data = new Vector4(light.shadowStrength, shadowedOtherLightCount, isPoint?1f:0f, maskChannel);shadowedOtherLightCount += newLightCount;return data;
}
2.2 Rendering Point Shadows
首先实现点光源阴影渲染,同聚光灯一样,只不过它要渲染6次,同时获取渲染矩阵和裁剪参数的接口也不同。
然后在渲染其它阴影时,判断如果是点光源,就调用点光源阴影渲染
void RenderOtherShadows()
{...// 渲染每个光源的阴影for (int i = 0; i < shadowedOtherLightCount;){// 渲染 point 阴影if (shadowedOtherLights[i].isPoint){RenderPointShadows(i, split, tileSize);i += 6;}// 渲染 spot 阴影else{RenderSpotShadows(i, split, tileSize);i += 1;}}...
}void RenderPointShadows(int index, int split, int tileSize)
{ShadowedOtherLight light = shadowedOtherLights[index];// 设置阴影渲染参数var shadowSettings = new ShadowDrawingSettings(cullingResults,light.visibleLightIndex,BatchCullingProjectionType.Perspective // 其它光源阴影用透视投影);// 每个图素在1单位距离上的大小。由于点光源每个面都是90度的视锥,因此这里是2/tileSizefloat texelSize = 2f / tileSize;// 根据filterMode,对图素大小进行缩放,避免 PCF等级提升导致的毛刺float filterSize = texelSize * ((float)settings.other.filter + 1f);// normalBias 乘以每个图素的对角线长度float bias = light.normalBias * filterSize * 1.4142136f;float tileScale = 1f / split;// 渲染点光源的6个面for (int i = 0; i < 6; i++){// 计算阴影矩阵和剔除数据cullingResults.ComputePointShadowMatricesAndCullingPrimitives(light.visibleLightIndex, (CubemapFace)i, 0f, out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);shadowSettings.splitData = splitData;// 设置视口,在对应的 tile 上渲染阴影Vector2 offset = SetViewport(index+i, split, tileSize);// 记录采样 shadow map 时需要的参数// 生成将世界坐标变换到阴影贴图UV坐标的矩阵otherShadowMatrices[index+i] = ConvertToAtlasMatrix(projectionMatrix * viewMatrix, offset, split);SetOtherTileData(index+i, offset, tileScale, bias);// 设置阴影渲染的投影矩阵buffer.SetViewProjectionMatrices(viewMatrix, projectionMatrix);buffer.SetGlobalDepthBias(0, light.slopeScaleBias);ExecuteBuffer();// 绘制 spot 阴影context.DrawShadows(ref shadowSettings);buffer.SetGlobalDepthBias(0, 0);}
}
如下图,一个点光源渲染了6个 shadow map
2.3 Sampling Point Shadows
采样点光源阴影,主要是要根据像素相对于光源的位置,确定采样哪个 shadow tile,以及确定计算 normal bias 缩放的平面
/////////////////////////////////
// shadow.hlsl
// 首先扩展其它光源阴影的数据结构,以支持点光源
// point / spot 光源 shadow 数据,包括
// 是否是点光源
// 像素到光源的方向
struct OtherShadowData
{bool isPoint; // 是否是点光源float strength; // 阴影强度int tileIndex; // 在 shadow atlas 中的 tile 索引int shadowMaskChannel; // 在 shadow mask map 中的通道索引float3 lightPositionWS; // 光源位置float3 spotDirectionWS; // 聚光灯方向float3 lightDirectionWS; // 像素到光源的方向
};//////////////////////////////////////////
// Light.hlsl
// 填充点光源阴影需要的数据OtherShadowData GetOtherLightShadowData(int index)
{OtherShadowData data;data.isPoint = _OtherLightShadowData[index].z == 1.0f;data.strength = _OtherLightShadowData[index].x;data.tileIndex = _OtherLightShadowData[index].y;data.shadowMaskChannel = _OtherLightShadowData[index].w;data.lightPositionWS = 0.0; // 这里设置默认值,在 GetOtherLight 函数中赋值data.spotDirectionWS = 0.0;data.lightDirectionWS = 0.0;return data;
}Light GetOtherLight(int index, Surface surfaceWS, ShadowData shadowData)
{...otherShadowData.lightDirectionWS = light.direction;float shadowMaskAttenuation = GetOtherShadowAttenuation(otherShadowData, shadowData, surfaceWS); ...
}///////////////////////////////////////
// shadow.hlsl
// 实现点光源阴影采样// 获取 Point/Spot 的实时阴影
float GetOtherShadow(OtherShadowData other, ShadowData global, Surface surfaceWS)
{// 定义点光源的 6 个计算 normal bias 缩放的平面,平面方向指向与渲染 shadow map 是相反的方向static const float3 pointShadowPlanes[6] ={float3(-1.0, 0.0, 0.0), // -xfloat3(1.0, 0.0, 0.0), // xfloat3(0.0, -1.0, 0.0), // -yfloat3(0.0, 1.0, 0.0), // yfloat3(0.0, 0.0, -1.0), // -zfloat3(0.0, 0.0, 1.0) // z};float tileIndex = other.tileIndex;float3 lightPlane = other.spotDirectionWS;if(other.isPoint){// 根据像素到光源的方向,确定渲染第几个 shadow mapfloat offset = CubeMapFaceID(-other.lightDirectionWS);tileIndex += offset;// 选择平面lightPlane = pointShadowPlanes[offset];}float4 tileData = _OtherShadowTiles[tileIndex];// 计算像素到光源的距离float distToLight = dot(other.lightPositionWS, lightPlane);// 根据距离计算法线偏移float normalBiasScale = distToLight * tileData.w;// 法线偏移float3 normalBias = surfaceWS.interplotedNormal * normalBiasScale; // 计算采样坐标float4 positionSTS = mul(_OtherShadowMatrices[tileIndex], float4(surfaceWS.position + normalBias, 1.0));return FilterOtherShadow(positionSTS.xyz/positionSTS.w, tileData.xyz);
}
如下图,点光源可以投射阴影了
2.4 Drawing the Correct Faces
点光源阴影没有 shadow acne 是问题,但是可以看到上图中,光穿过了表面,产生了漏光现象
这是因为 Unity 在渲染点光源阴影时,渲染的是背面三角形,这样能避免大多数 ance 现象,但是会导致漏光现象(想象成墙很薄)。我们无法改变渲染方式,但是可以修改矩阵,给一个负的缩放,让反面重新变成正面
void RenderPointShadows(int index, int split, int tileSize)
{...// 渲染点光源的6个面// 以 X,-X,Y,-Y,Z,-Z 的顺序渲染for (int i = 0; i < 6; i++){// 计算阴影矩阵和剔除数据cullingResults.ComputePointShadowMatricesAndCullingPrimitives(light.visibleLightIndex, (CubemapFace)i, fovBias, out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);// 点光源的 view 矩阵需要对 Z 轴取反,反转模型。因为 unity 用反面渲染点光源阴影,导致漏光问题// 我们通过反转模型来解决达到渲染正面的效果// 这一步其实可选:// 不希望漏光,且不会把光源布置到多边形内部,则翻转// unity 的效果也是对的,比如进入多边形内部,渲染反面才能保证正确的阴影效果viewMatrix.m11 = -viewMatrix.m11;viewMatrix.m12 = -viewMatrix.m12;viewMatrix.m13 = -viewMatrix.m13;...}...
}
翻转后,需要调整光源的bias ,来消除错误的阴影
下图是 bias 为 0 时的效果
下图是 bias 为 1.5 时的效果,为正常效果
unity 的方案也不错,而且当光源进入多边形内部时,完全在阴影里,这其实也是正确的
2.5 Field of View Bias
point light shadow map 的六个面构成 cube map,在面连接的地方,采样结果会不连续。 可以通过扩大 fov 来降低这种影响。unity 的 ComputePointShadowMatricesAndCullingPrimitives 接口提供了参数来修改 fov。fov bias 计算原理:
1f + bias + filterSize 就是加上红色部分后,左边三角形的底边的长度,就是 ,通过
得到半角弧度,乘以 Rad2Deg 变成角度,再乘2减90,得到红色顶角大于90°的部分。最终代码如下:
float fovBias =Mathf.Atan(1f + bias + filterSize) * Mathf.Rad2Deg * 2f - 90f;// 渲染点光源的6个面// 以 X,-X,Y,-Y,Z,-Z 的顺序渲染for (int i = 0; i < 6; i++){// 计算阴影矩阵和剔除数据cullingResults.ComputePointShadowMatricesAndCullingPrimitives(light.visibleLightIndex, (CubemapFace)i, fovBias, out Matrix4x4 viewMatrix, out Matrix4x4 projectionMatrix,out ShadowSplitData splitData);
事实上,我的项目中没有出现上面的问题,即使不应用 fovBias 也没问题,留待后续验证吧