GameTech/URP 渲染实战

案例 24.5Lit Graph 的溶解效果入门

目标:保留 URP Lit 的完整 PBR 光照,只在表面上做噪声溶解和边缘发光。这类“表面效果 + 标准光照”正是 Lit Graph 最擅长的:不用管任何光照代码,阴影、GI、雾、Forward+ 全部自动正确。

  1. 新建 Lit Shader Graph,Graph Settings 勾选 Alpha Clipping,Master Stack 出现 Alpha 和 Alpha Clip Threshold。
  2. Blackboard 添加 Noise(Texture2D)、Progress(Float,Slider 0~1)、Edge Width(Float)、Edge Color(Color,HDR 模式)。
  3. Sample Texture 2D 采样 Noise 取 R,与 Progress、Edge Width 一起接入 Custom Function Dissolve(也可以用 Subtract、Step、Smoothstep 节点拼出来)。
  4. Dissolve 的 AlphaOut 接 Alpha,Alpha Clip Threshold 填 0.5;Edge × Edge Color 接 Emission;Base Color 乘以 (1 − Edge),让发光边缘不再叠加漫反射。
Assets/Cookbook/Ch24/StylizedFunctions.hlsl(节选:dissolve)
// 溶解:Noise 小于 Progress 的部分被裁掉;在裁剪边界外侧 EdgeWidth 范围内输出发光遮罩。
// AlphaOut 直接接 Master Stack 的 Alpha,Alpha Clip Threshold 填 0.5。
void Dissolve_float(float Noise, float Progress, float EdgeWidth, out float AlphaOut, out float Edge)
{
    // 让 Progress = 0 时完全不溶解、= 1 时完全消失:把阈值区间扩展到 [-EdgeWidth, 1]
    float threshold = Progress * (1.0 + EdgeWidth) - EdgeWidth;
    float d = Noise - threshold;           // > 0 保留,< 0 裁掉
    AlphaOut = step(0.0, d);
    Edge = 1.0 - smoothstep(0.0, EdgeWidth, d);   // 越靠近裁剪边界越接近 1
    Edge *= AlphaOut;
}

void Dissolve_half(half Noise, half Progress, half EdgeWidth, out half AlphaOut, out half Edge)
{
    float a, e;
    Dissolve_float(Noise, Progress, EdgeWidth, a, e);
    AlphaOut = a; Edge = e;
}
// 溶解:Noise 小于 Progress 的部分被裁掉;在裁剪边界外侧 EdgeWidth 范围内输出发光遮罩。
// AlphaOut 直接接 Master Stack 的 Alpha,Alpha Clip Threshold 填 0.5。
void Dissolve_float(float Noise, float Progress, float EdgeWidth, out float AlphaOut, out float Edge)
{
    // 让 Progress = 0 时完全不溶解、= 1 时完全消失:把阈值区间扩展到 [-EdgeWidth, 1]
    float threshold = Progress * (1.0 + EdgeWidth) - EdgeWidth;
    float d = Noise - threshold;           // > 0 保留,< 0 裁掉
    AlphaOut = step(0.0, d);
    Edge = 1.0 - smoothstep(0.0, EdgeWidth, d);   // 越靠近裁剪边界越接近 1
    Edge *= AlphaOut;
}

void Dissolve_half(float Noise, float Progress, float EdgeWidth, out float AlphaOut, out float Edge)
{
    float a, e;
    Dissolve_float(Noise, Progress, EdgeWidth, a, e);
    AlphaOut = a; Edge = e;
}

Progress 映射到 [−EdgeWidth, 1] 的阈值区间,保证 Progress = 0 时一点都不溶解(连发光边都没有)、Progress = 1 时完全消失。Lit Graph 的 Forward Pass 最终就是把 Master Stack 的各个 Block 填进 SurfaceData 再交给 UniversalFragmentPBR,等价代码如下:

Assets/Cookbook/Ch24/SGDissolveEquivalent.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    float alpha, edge;
    DissolveGraph(i.uv, alpha, edge);
    clip(alpha - 0.5);                         // Alpha Clip Threshold = 0.5

    SurfaceData surface = (SurfaceData)0;      // ← Master Stack 的各个 Block
    surface.albedo     = _BaseColor.rgb * (1 - edge);
    surface.metallic   = _Metallic;
    surface.smoothness = _Smoothness;
    surface.occlusion  = 1;
    surface.emission   = edge * _EdgeColor.rgb;
    surface.alpha      = 1;
    surface.normalTS   = half3(0, 0, 1);

    InputData input = (InputData)0;
    input.positionWS = i.positionWS;
    input.normalWS = normalize(i.normalWS);
    input.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    input.shadowCoord = TransformWorldToShadowCoord(i.positionWS);
    input.bakedGI = SampleSH(input.normalWS);
    input.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
    input.shadowMask = half4(1, 1, 1, 1);
    return UniversalFragmentPBR(input, surface);
}
vec4 frag()
{
    float alpha, edge;
    DissolveGraph(uv, alpha, edge);
    if (alpha - 0.5 < 0.0) discard;              // Alpha Clip Threshold = 0.5

    SurfaceData surface = InitSurfaceData();     // ← Master Stack 的各个 Block
    surface.albedo     = _BaseColor.rgb * (1.0 - edge);
    surface.metallic   = _Metallic;
    surface.smoothness = _Smoothness;
    surface.occlusion  = 1.0;
    surface.emission   = edge * _EdgeColor.rgb;
    surface.alpha      = 1.0;
    surface.normalTS   = vec3(0, 0, 1);

    InputData inputData = InitInputData();
    inputData.positionWS = positionWS;
    inputData.normalWS = normalize(normalWS);
    inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
    inputData.shadowCoord = TransformWorldToShadowCoord(positionWS);
    inputData.bakedGI = SampleSH(inputData.normalWS);
    inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    inputData.shadowMask = vec4(1, 1, 1, 1);
    return UniversalFragmentPBR(inputData, surface);
}

手写时最容易忘的是:溶解掉的部分不应该再投影。Shader Graph 会自动把 Alpha Clip 带进 ShadowCaster 和 DepthOnly Pass;手写就必须在每个 Pass 里重复同样的裁剪:

Assets/Cookbook/Ch24/SGDissolveEquivalent.shader(节选:caster)
Pass
{
    // Shader Graph 会自动把 Alpha Clip 带进 ShadowCaster / DepthOnly;手写时最容易忘
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0
    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    #include "Assets/Cookbook/Ch23/Ch23Common.hlsl"
    struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; };
    struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
    Varyings vert(Attributes v)
    {
        Varyings o;
        CasterAttributes c; c.positionOS = v.positionOS; c.normalOS = v.normalOS;
        o.positionCS = Ch23ShadowCasterVert(c);
        o.uv = v.uv;
        return o;
    }
    half4 frag(Varyings i) : SV_Target
    {
        float alpha, edge;
        DissolveGraph(i.uv, alpha, edge);
        clip(alpha - 0.5);
        return 0;
    }
    ENDHLSL
}
Pass
{
    // Shader Graph 会自动把 Alpha Clip 带进 ShadowCaster / DepthOnly;手写时最容易忘
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0
    GLSLPROGRAM
    #include "Ch23/Ch23Common.glsl"
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()
    {
        gl_Position = Ch23ShadowCasterVert();
        uv = texcoord0.xy;
    }
    #endif
    #ifdef FRAGMENT
    void main()
    {
        float alpha, edge;
        DissolveGraph(uv, alpha, edge);
        if (alpha - 0.5 < 0.0) discard;
        fragColor = vec4(0.0);
    }
    #endif
    ENDGLSL
}
图 24-8溶解进度 0.15 / 0.45 / 0.75(由等价手写 Shader 渲染,开启 Bloom 与 Neutral 色调映射)。注意地面上的影子随溶解同步出现孔洞——ShadowCaster 里做了同样的裁剪

动画方面,拍图时 _Time 不可控,所以这里的 Progress 是材质参数;实际使用时可以在图里用 Time 节点驱动,或者由脚本 material.SetFloat("_Progress", …) 控制,后者更容易与游戏逻辑同步。