GameTech/URP 渲染实战

案例 7.7在自写 Shader 中接入 URP 的单步视差进阶

目标:写一个可以在“无视差 / 单步 / Steep / POM”之间切换的 Shader,先实现前两种。四种模式用 [KeywordEnum(None, Simple, Steep, POM)] 声明,生成 _MODE_NONE 等四个关键字,每种模式编译成独立变体,运行时没有分支开销。完整的片元着色器如下:

Assets/Cookbook/Ch07/Parallax.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float2 uv = i.uv;
    float2 dx = ddx(uv), dy = ddy(uv);
    // 视线转到切线空间。URP 的 GetViewDirectionTangentSpace 要求传入“插值后未归一化”的 N/T,
    // 它内部用同一个缩放因子处理三根轴,保持与 MikkTSpace 一致。
    half3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 viewDirTS = GetViewDirectionTangentSpace(i.tangentWS, i.normalWS, viewDirWS);
    float hitDepth = 0;

#if defined(_MODE_SIMPLE)
    // URP Lit 的做法(ParallaxMapping.hlsl):采样一次高度,沿视线偏移一步
    uv += ParallaxMapping(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), viewDirTS, _Parallax, uv);
#elif defined(_MODE_STEEP)
    uv = SteepParallax(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), uv, normalize(viewDirTS),
                       _Parallax, _MinLayers, _MaxLayers, hitDepth);
#elif defined(_MODE_POM)
    uv = ParallaxOcclusion(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), uv, normalize(viewDirTS),
                           _Parallax, _MinLayers, _MaxLayers, hitDepth);
#endif

    // 之后所有贴图都用偏移后的 UV 采样,并沿用原始 UV 的导数,避免台阶边缘的 mip 跳变
    half3 albedo = SAMPLE_TEXTURE2D_GRAD(_BaseMap, sampler_BaseMap, uv, dx, dy).rgb;
    half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D_GRAD(_BumpMap, sampler_BumpMap, uv, dx, dy), _BumpScale);
    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3x3 tbn = half3x3(i.tangentWS.xyz, bitangent, i.normalWS);
    half3 normalWS = NormalizeNormalPerPixel(TransformTangentToWorld(normalTS, tbn));

    half selfShadow = 1;
#if defined(_PARALLAX_SHADOW) && (defined(_MODE_STEEP) || defined(_MODE_POM))
    // 把主光方向也转到切线空间:TBN 是正交基,转置即逆,mul(tbn, v) = (T·v, B·v, N·v)
    half3 lightDirTS = normalize(mul(tbn, GetMainLight().direction));
    selfShadow = ParallaxSelfShadow(TEXTURE2D_ARGS(_ParallaxMap, sampler_ParallaxMap), uv, dx, dy,
                                    hitDepth, lightDirTS, _Parallax, _ShadowSteps, _ShadowSoftness);
#endif

    half3 color = Ch07Shade(i.positionWS, normalWS, albedo, 0.45, 0.12, selfShadow);
    return half4(color, 1);
}
void main()
{
    vec2 uv = uv0;
    vec2 dx = dFdx(uv), dy = dFdy(uv);
    // 视线转到切线空间。URP 的 GetViewDirectionTangentSpace 要求传入“插值后未归一化”的 N/T,
    // 它内部用同一个缩放因子处理三根轴,保持与 MikkTSpace 一致。
    vec3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 viewDirTS = GetViewDirectionTangentSpace(tangentWS, normalWS, viewDirWS);
    float hitDepth = 0.0;

#if defined(_MODE_SIMPLE)
    // URP Lit 的做法(ParallaxMapping.hlsl):采样一次高度,沿视线偏移一步
    uv += ParallaxMapping(_ParallaxMap, viewDirTS, _Parallax, uv);
#elif defined(_MODE_STEEP)
    uv = SteepParallax(_ParallaxMap, uv, normalize(viewDirTS), _Parallax, _MinLayers, _MaxLayers, hitDepth);
#elif defined(_MODE_POM)
    uv = ParallaxOcclusion(_ParallaxMap, uv, normalize(viewDirTS), _Parallax, _MinLayers, _MaxLayers, hitDepth);
#endif

    // 之后所有贴图都用偏移后的 UV 采样,并沿用原始 UV 的导数,避免台阶边缘的 mip 跳变
    vec3 albedo = textureGrad(_BaseMap, uv, dx, dy).rgb;
    vec3 normalTS = UnpackNormalScale(textureGrad(_BumpMap, uv, dx, dy), _BumpScale);
    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    mat3 tbn = mat3(tangentWS.xyz, bitangent, normalWS);
    vec3 nWS = NormalizeNormalPerPixel(TransformTangentToWorld(normalTS, tbn));

    float selfShadow = 1.0;
#if defined(_PARALLAX_SHADOW) && (defined(_MODE_STEEP) || defined(_MODE_POM))
    // 把主光方向也转到切线空间:TBN 是正交基,转置即逆。
    // HLSL 的 mul(tbn, v)(tbn 按行放 T/B/N)= (T·v, B·v, N·v),GLSL 里写成 v * tbn(tbn 按列放)
    vec3 lightDirTS = normalize(GetMainLight().direction * tbn);
    selfShadow = ParallaxSelfShadow(_ParallaxMap, uv, dx, dy,
                                    hitDepth, lightDirTS, _Parallax, _ShadowSteps, _ShadowSoftness);
#endif

    vec3 color = Ch07Shade(positionWS, nWS, albedo, 0.45, 0.12, selfShadow);
    fragColor = vec4(color, 1.0);
}

单步视差只有一行:uv += ParallaxMapping(...)。之后所有贴图——颜色、法线——都用偏移后的 uv 采样,这一点很重要:如果只偏移颜色贴图而法线贴图仍用原 uv,光照与图案会错位。另外,采样改用了 SAMPLE_TEXTURE2D_GRAD 并传入原始 uv 的导数。偏移量在砖缝边缘是突变的,若让 GPU 用偏移后 uv 自动求导,这些地方的导数会异常地大,选到极低的 mip,出现一圈模糊的“描边”。用原始 uv 的导数可以避免这个问题。单步视差的效果见下一个案例的对比图(图 7-15 第二格):砖缝看上去有了一点深度,但砖块侧面被拉成了条纹,掠射角下尤其明显。