GameTech/URP 渲染实战

案例 6.3接收主光源阴影入门

目标:让 Lambert Shader 接收主光源的实时阴影,支持单张阴影图、级联、屏幕空间阴影和三档软阴影,并支持材质级的“不接收阴影”开关。

关键字

Assets/Cookbook/Ch06/ShadowReceive.shader(节选:keywords)
// 主光阴影:无 / 单张阴影图 / 级联 / 屏幕空间(由 URP Asset 与 Renderer 设置决定,运行时自动开关)
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
// 软阴影:_SHADOWS_SOFT 在运行时按每盏灯的质量分支;LOW/MEDIUM/HIGH 是给“必须静态分支”的平台准备的
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
// 材质级开关:不接收阴影
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF
// 主光阴影:无 / 单张阴影图 / 级联 / 屏幕空间(由 URP Asset 与 Renderer 设置决定,运行时自动开关)。
// 软阴影:_SHADOWS_SOFT 在运行时按每盏灯的质量分支。
// 这两组是 URP 全局关键字,网页版引擎始终定义 _MAIN_LIGHT_SHADOWS 与 _SHADOWS_SOFT,不用声明。
// 材质级开关:不接收阴影
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF

这三行与 URP 14 Lit.shader 的写法一致:

片元代码

Assets/Cookbook/Ch06/ShadowReceive.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb;
    half3 normalWS = normalize(input.normalWS);

    // 世界坐标 -> 阴影图坐标(xy = 阴影图 UV,z = 在光源空间里的深度)。
    // 开了级联时,这里会先按距离选出级联号,再用对应的矩阵变换
    float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS);

    // 采样阴影图并与 z 比较:1 = 受光,0 = 在阴影里(软阴影时是 0~1 的过渡)
    half shadow = MainLightRealtimeShadow(shadowCoord);

    // 超出“阴影距离”的地方,阴影要平滑淡出,而不是突然消失
    shadow = lerp(shadow, 1.0, GetMainLightShadowFade(input.positionWS));

    Light mainLight = GetMainLight();
    half3 radiance = mainLight.color * mainLight.distanceAttenuation * shadow;
    half3 color = albedo * radiance * saturate(dot(normalWS, mainLight.direction));
    color += albedo * SampleSH(normalWS) * _AmbientStrength;
    return half4(color, 1);
}
void main()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
    vec3 N = normalize(normalWS);

    // 世界坐标 -> 阴影图坐标(xy = 阴影图 UV,z = 在光源空间里的深度)。
    // 开了级联时,这里会先按距离选出级联号,再用对应的矩阵变换
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);

    // 采样阴影图并与 z 比较:1 = 受光,0 = 在阴影里(软阴影时是 0~1 的过渡)。
    // URP 的 MainLightRealtimeShadow 在 _RECEIVE_SHADOWS_OFF 时直接返回 1(宏 MAIN_LIGHT_CALCULATE_SHADOWS 未定义)
#if defined(_RECEIVE_SHADOWS_OFF)
    float shadow = 1.0;
#else
    float shadow = MainLightRealtimeShadow(shadowCoord);
#endif

    // 超出“阴影距离”的地方,阴影要平滑淡出,而不是突然消失
    shadow = mix(shadow, 1.0, GetMainLightShadowFade(positionWS));

    Light mainLight = GetMainLight();
    vec3 radiance = mainLight.color * mainLight.distanceAttenuation * shadow;
    vec3 color = albedo * radiance * saturate(dot(N, mainLight.direction));
    color += albedo * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(color, 1.0);
}
  1. TransformWorldToShadowCoord:开了级联时先调用 ComputeCascadeIndex,再用对应的 _MainLightWorldToShadow[i] 变换;开了屏幕空间阴影时则返回屏幕坐标。
  2. MainLightRealtimeShadow:按关键字选择采样方式(屏幕空间纹理 / 硬阴影 / 某档 PCF),乘上灯光的 Shadow Strength,并对超出阴影图深度范围的坐标返回 1。
  3. GetMainLightShadowFade:返回 0~1 的淡出系数,用它把阴影插值回 1。URP 自己的 MainLightShadow() 函数把实时阴影、烘焙阴影(Shadowmask)和淡出组合在一起,接入光照贴图时应该用它,这里为了看清每一步拆开来写。
深入:阴影坐标在哪个阶段算Shadows.hlsl 在只有一个级联(或屏幕空间阴影)时会定义 REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR,提示你可以在顶点着色器里算好阴影坐标再插值——单个矩阵是线性变换,逐顶点算再插值与逐像素算结果相同,还省了一次矩阵乘法。多级联时必须逐像素计算,因为同一个三角形可能横跨两个级联。案例 6.9 的 ClassicLit 两种情况都处理了。
图 6-13地面与三个物体都使用本案例的 Shader。左:材质打开 _RECEIVE_SHADOWS_OFF,物体仍然投射阴影(写进了阴影图),但谁也不接收;中:主光源为硬阴影;右:软阴影(Medium)
图 6-14阴影边缘特写(阴影图 1024)。从左到右:Hard、Soft Low、Soft Medium、Soft High。硬阴影的方块锯齿清晰可见;Low 只是稍作柔化;Medium 与 High 的帐篷滤波让边缘越来越宽、越来越平滑,细杆的远端阴影也随之变淡