GameTech/URP 渲染实战

案例 12.7背光的树叶进阶

目标:做一组双面的程序化树叶,对比无透射、均匀透射、按叶脉厚度透射三种情况。

叶片形状和叶脉都在 Shader 里程序生成:UV 的 y 从叶柄到叶尖,宽度按正弦的幂变化,再乘一个线性收窄;主脉是 x = 0 附近的一条细线,侧脉是斜向重复的条纹。叶脉比叶肉厚,透光少:

Assets/Cookbook/Ch12/Translucent.shader(节选:leaf)
// 程序化叶片:UV 的 y 从叶柄(0)到叶尖(1)。返回 x=是否在叶内(符号距离),y=叶脉强度(0~1)
float2 LeafShape(float2 uv)
{
    float x = uv.x - 0.5, y = uv.y;
    float halfWidth = 0.44 * pow(saturate(sin(PI * y)), 0.75) * (1.0 - 0.35 * y);
    float inside = halfWidth - abs(x);
    float mid = 1.0 - smoothstep(0.004, 0.018, abs(x));                       // 主脉
    float f = frac((y - abs(x) * 0.9) * 7.0);                                 // 斜向重复的侧脉
    float side = 1.0 - smoothstep(0.03, 0.09, min(f, 1.0 - f));
    side *= smoothstep(0.02, 0.06, abs(x)) * smoothstep(0.0, 0.1, inside);    // 侧脉,离边缘淡出
    return float2(inside, saturate(max(mid, side * 0.65)));
}
// 程序化叶片:UV 的 y 从叶柄(0)到叶尖(1)。返回 x=是否在叶内(符号距离),y=叶脉强度(0~1)
vec2 LeafShape(vec2 uv)
{
    float x = uv.x - 0.5, y = uv.y;
    float halfWidth = 0.44 * pow(saturate(sin(PI * y)), 0.75) * (1.0 - 0.35 * y);
    float inside = halfWidth - abs(x);
    float mid = 1.0 - smoothstep(0.004, 0.018, abs(x));                       // 主脉
    float f = fract((y - abs(x) * 0.9) * 7.0);                                // 斜向重复的侧脉
    float side = 1.0 - smoothstep(0.03, 0.09, min(f, 1.0 - f));
    side *= smoothstep(0.02, 0.06, abs(x)) * smoothstep(0.0, 0.1, inside);    // 侧脉,离边缘淡出
    return vec2(inside, saturate(max(mid, side * 0.65)));
}
Assets/Cookbook/Ch12/Translucent.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float NoL = saturate(dot(m.N, L));
    float3 front = (m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04)) * PI * NoL;

    // 透射:把光方向沿法线“扭”一点(模拟光在叶片内部的散射偏折),
    // 再看视线与 -L' 的接近程度——正对着光源看时最亮
    float3 Lt = normalize(L + m.N * _Distortion);
    float VoLt = pow(saturate(dot(m.V, -Lt)), _TransPower) * _TransScale;
    float3 trans = (VoLt + _TransAmbient) * (1.0 - m.thickness) * _TransColor.rgb * m.albedo * _Transmission;

    // 透射光走的是“叶片背面”的阴影:正面阴影图对它没有意义,这里只用距离衰减
    return front * light.color * (light.distanceAttenuation * light.shadowAttenuation)
         + trans * light.color * light.distanceAttenuation;
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    float NoL = saturate(dot(m.N, L));
    vec3 front = (m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04)) * PI * NoL;

    // 透射:把光方向沿法线“扭”一点(模拟光在叶片内部的散射偏折),
    // 再看视线与 -L' 的接近程度——正对着光源看时最亮
    vec3 Lt = normalize(L + m.N * _Distortion);
    float VoLt = pow(saturate(dot(m.V, -Lt)), _TransPower) * _TransScale;
    vec3 trans = (VoLt + _TransAmbient) * (1.0 - m.thickness) * _TransColor.rgb * m.albedo * _Transmission;

    // 透射光走的是“叶片背面”的阴影:正面阴影图对它没有意义,这里只用距离衰减
    return front * light.color * (light.distanceAttenuation * light.shadowAttenuation)
         + trans * light.color * light.distanceAttenuation;
}

透射项没有乘阴影衰减:从正面看,叶片背后接收的光才是透射的来源,而正面的阴影图查询结果反映的是“正面是否被挡”,用在这里没有意义(单片叶子时,它甚至会被自己挡住)。更完整的做法是从光源一侧采样阴影图,得到“光穿进物体之前是否被挡”,那需要把采样点挪到背面,留作练习。

叶片双面渲染(Cull Off)时,背面的法线要翻转,否则背面的光照是反的。片元着色器可以用 SV_IsFrontFace 语义拿到当前片元是否是正面。形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道这件事,阴影会是一个方块,所以这里手写了一个带 clip 的阴影 Pass,写法与 URP 的 ShadowCasterPass.hlsl 一致(法线偏移、点光源/方向光两种光方向、近裁剪面钳制):

Assets/Cookbook/Ch12/Translucent.shader(节选:shadowpass)
// 叶片形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道,所以自己写一个带 clip 的
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0 Cull Off
    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_instancing
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
    float3 _LightDirection;
    float3 _LightPosition;
    struct A { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID };
    struct V { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
    V vert(A v)
    {
        UNITY_SETUP_INSTANCE_ID(v);
        V o;
        float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
        float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
    #if _CASTING_PUNCTUAL_LIGHT_SHADOW
        float3 L = normalize(_LightPosition - positionWS);
    #else
        float3 L = _LightDirection;
    #endif
        o.positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, L));
    #if UNITY_REVERSED_Z
        o.positionCS.z = min(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #else
        o.positionCS.z = max(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #endif
        o.uv = v.uv;
        return o;
    }
    half4 frag(V i) : SV_Target { clip(LeafShape(i.uv).x); return 0; }
    ENDHLSL
}
// 叶片形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道,所以自己写一个带 clip 的
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0 Cull Off
    GLSLPROGRAM
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()
    {
        vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
        vec3 normalWS = TransformObjectToWorldNormal(normalOS);
        // _CASTING_PUNCTUAL_LIGHT_SHADOW 在 GLSL 版里是个 uniform
        vec3 L = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;
        vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, L));
        // WebGL 深度不反转(UNITY_REVERSED_Z = 0),走 max 分支
        positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);
        uv = texcoord0.xy;
        gl_Position = positionCS;
    }
    #endif
    #ifdef FRAGMENT
    void main() { if (LeafShape(uv).x < 0.0) discard; fragColor = vec4(0.0); }
    #endif
    ENDGLSL
}
图 12-22太阳在叶片后方、朝相机照来。左:无透射,只有一点环境光,叶片发暗;中:均匀厚度的透射,叶肉和叶脉一起透亮;右:按叶脉厚度透射,叶脉透光少,在亮的叶肉中显出暗色纹路

扩展:用同一个透射项做玉石和蜡烛

把透射项用在封闭的实体上(玉雕、蜡烛、皮肤的耳朵),关键在厚度。一个常见的离线做法是“反向 AO”:在每个顶点处沿法线的反方向朝物体内部发射一圈射线,测量平均穿出距离,归一化后存进顶点色。厚处透得少、薄处透得多,配合附加光(例如把点光源放在玉雕背后),就有很不错的“通透”感。HDRP 的 Thickness Map 与本节的厚度含义相同。