GameTech/URP 渲染实战

案例 15.7低多边形 flat shading:用 ddx/ddy 求面法线入门

目标:不修改网格,让光滑物体呈现出“一面一色”的低多边形(Low Poly)风格。

原理:常规做法是把网格拆开,让每个三角形独占三个顶点、写入相同的面法线。这会把顶点数变成三角形数的三倍,而且必须在建模或导入阶段处理。另一种做法完全在片元着色器里完成:GPU 以 2×2 的像素块(Quad)为单位执行片元着色器,ddx(v) 返回同一块中右邻像素与当前像素的 v 之差,ddy(v) 返回下(或上)邻像素之差。对世界坐标求导:

∂P∂x≈ddx(P),∂P∂y≈ddy(P),N=normalize(ddy(P)×ddx(P))

两个差向量都是“沿屏幕横向/纵向走一个像素时,世界坐标移动了多少”,它们都躺在三角形所在的平面内,叉乘就得到平面的法线。而世界坐标在一个三角形内部是线性插值的(透视校正后依然在同一平面上),所以无论在三角形哪个位置求,方向都一样——整面常量,正是 flat shading 需要的。

P(x,y)P(x+1,y)P(x,y+1) 一个 2×2 像素块(Quad) ddx(P) = P(x+1,y) − P(x,y) ddy(P) = P(x,y+1) − P(x,y) 同一块里的 4 个像素共享这两个差值 ddx ddy 两者都在三角形平面内
图 15-15屏幕空间导数:ddx/ddy 是相邻像素的差,叉乘得到三角形平面的法线

叉乘的方向是这里唯一的坑。ddy 是“屏幕 y 增大的方向”,而屏幕 y 轴到底朝上还是朝下,取决于平台(D3D/Metal 的像素坐标原点在左上,OpenGL 在左下)以及当前是否在往 RT 里渲染(URP 会翻转投影矩阵,见第 1 章的 _ProjectionParams.x)。与其逐一判断,不如利用一个简单的事实:我们看到的正面,法线一定朝向相机。算出叉乘后,和视线方向点乘,如果是负的就翻过来:

Assets/Cookbook/Ch15/FlatShading.shader(节选:facenormal)
float3 FaceNormal(float3 positionWS)
{
    // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
    // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
    float3 n = normalize(cross(ddy(positionWS), ddx(positionWS)));
    // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
    // 正面一定朝向相机,所以用视线方向把它翻正,最省心
    float3 V = _WorldSpaceCameraPos - positionWS;
    return dot(n, V) < 0 ? -n : n;
}
vec3 FaceNormal(vec3 posWS)
{
    // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
    // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
    vec3 n = normalize(cross(dFdy(posWS), dFdx(posWS)));
    // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
    // 正面一定朝向相机,所以用视线方向把它翻正,最省心
    vec3 V = _WorldSpaceCameraPos - posWS;
    return dot(n, V) < 0.0 ? -n : n;
}

演示场景是一块在顶点着色器里用噪声抬高的地形:顶点按世界 xz 采样噪声得到高度,用有限差分求出平滑法线(下一章顶点动画会详细讲这个方法),低于水位的部分压平。颜色按高度和坡度分带——注意代码里的颜色常量最后经过了 SRGBToLinear:在线性空间的工程里,Shader 里写死的颜色常量会被当作线性值直接参与运算,而我们挑颜色时是按“显示器上看起来”的 sRGB 值挑的,不转换的话所有颜色都会偏亮偏灰。材质面板上的 Color 属性 Unity 会自动转换,写死的常量不会。

Assets/Cookbook/Ch15/FlatShading.shader
// 低多边形(Low Poly)flat shading:不改网格,在片元里用 ddx/ddy 求三角形的面法线。
// 演示场景是一块在顶点着色器里用噪声抬高的地形,可以在“平滑法线 / 面法线”之间切换。
Shader "Cookbook/Ch15/FlatShading"
{
    Properties
    {
        _BaseColor ("Base Color (non-terrain)", Color) = (0.9, 0.55, 0.3, 1)
        [Toggle] _Flat ("Flat Shading (ddx/ddy)", Float) = 1
        [Toggle] _Terrain ("Terrain Mode", Float) = 0
        [NoScaleOffset] _HeightTex ("Height Noise", 2D) = "gray" {}
        _HeightScale ("Height", Range(0, 4)) = 1.6
        _NoiseTiling ("Noise Tiling", Float) = 0.08
        _WaterLevel ("Water Level", Float) = -0.6
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            float _Flat, _Terrain, _HeightScale, _NoiseTiling, _WaterLevel;
        CBUFFER_END
        TEXTURE2D(_HeightTex); SAMPLER(sampler_HeightTex);

        // 地形高度:世界 xz 采样噪声。顶点着色器里必须用 LOD 版本采样(没有导数可用)
        float TerrainHeight(float2 xz)
        {
            float h = SAMPLE_TEXTURE2D_LOD(_HeightTex, sampler_HeightTex, xz * _NoiseTiling, 0).r;
            h = (h - 0.45) * _HeightScale * 2.0;
            return max(h, _WaterLevel);     // 低于水位的部分压平成水面
        }

        // 所有 Pass 共用的顶点变形:地形模式下抬高顶点,并用有限差分算出平滑法线
        void Deform(inout float3 positionWS, inout float3 normalWS)
        {
            if (_Terrain < 0.5) return;
            const float e = 0.05;
            float h  = TerrainHeight(positionWS.xz);
            float hx = TerrainHeight(positionWS.xz + float2(e, 0));
            float hz = TerrainHeight(positionWS.xz + float2(0, e));
            positionWS.y += h;
            normalWS = normalize(float3(h - hx, e, h - hz));
        }
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"

            NprVaryings vert(NprAttributes v)
            {
                NprVaryings o;
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                Deform(positionWS, normalWS);
                o.positionWS = positionWS;
                o.normalWS = normalWS;
                o.positionCS = TransformWorldToHClip(positionWS);
                o.uv = v.uv; o.color = v.color; o.fogFactor = 0;
                return o;
            }

            float3 FaceNormal(float3 positionWS)
            {
                // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
                // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
                float3 n = normalize(cross(ddy(positionWS), ddx(positionWS)));
                // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
                // 正面一定朝向相机,所以用视线方向把它翻正,最省心
                float3 V = _WorldSpaceCameraPos - positionWS;
                return dot(n, V) < 0 ? -n : n;
            }

            half3 TerrainColor(float y, float3 N)
            {
                half3 c = half3(0.10, 0.32, 0.62);                                         // 水
                c = lerp(c, half3(0.80, 0.68, 0.42), step(_WaterLevel + 0.02, y));         // 沙
                c = lerp(c, half3(0.30, 0.55, 0.18), step(_WaterLevel + 0.25, y));         // 草
                c = lerp(c, half3(0.42, 0.37, 0.33), step(0.3, y) * step(N.y, 0.9));      // 陡坡变岩石
                c = lerp(c, half3(0.92, 0.93, 0.96), step(1.3, y));                        // 雪
                // 上面这些常量是按“显示器上看到的颜色”(sRGB)挑的,线性空间工程里要先转成线性值
                return SRGBToLinear(c);
            }

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = _Flat > 0.5 ? FaceNormal(i.positionWS) : normalize(i.normalWS);
                NprLight L = GetNprLight(i.positionWS, N);
                half3 albedo = _Terrain > 0.5 ? TerrainColor(i.positionWS.y, N) : _BaseColor.rgb;
                half3 col = albedo * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
                return half4(col, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment NprNullFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            float4 vert(NprShadowAttributes v) : SV_POSITION
            {
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                Deform(positionWS, normalWS);   // 阴影要和颜色 Pass 用同样的变形
                float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, _LightDirection));
            #if UNITY_REVERSED_Z
                positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
            #else
                positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
            #endif
                return positionCS;
            }
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment NprNullFrag
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            float4 vert(NprShadowAttributes v) : SV_POSITION
            {
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
                Deform(positionWS, normalWS);
                return TransformWorldToHClip(positionWS);
            }
            ENDHLSL
        }
    }
}
// 低多边形(Low Poly)flat shading:不改网格,在片元里用 ddx/ddy 求三角形的面法线。(GLSL 版,对应 Ch15/FlatShading.shader)
// 演示场景是一块在顶点着色器里用噪声抬高的地形,可以在“平滑法线 / 面法线”之间切换。
Shader "Cookbook/Ch15/FlatShading"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor;
        uniform float _Flat, _Terrain, _HeightScale, _NoiseTiling, _WaterLevel;
        uniform sampler2D _HeightTex;

        // 地形高度:世界 xz 采样噪声。顶点着色器里必须用 LOD 版本采样(没有导数可用)
        float TerrainHeight(vec2 xz)
        {
            float h = textureLod(_HeightTex, xz * _NoiseTiling, 0.0).r;
            h = (h - 0.45) * _HeightScale * 2.0;
            return max(h, _WaterLevel);     // 低于水位的部分压平成水面
        }

        // 所有 Pass 共用的顶点变形:地形模式下抬高顶点,并用有限差分算出平滑法线
        void Deform(inout vec3 posWS, inout vec3 nrmWS)
        {
            if (_Terrain < 0.5) return;
            const float e = 0.05;
            float h  = TerrainHeight(posWS.xz);
            float hx = TerrainHeight(posWS.xz + vec2(e, 0));
            float hz = TerrainHeight(posWS.xz + vec2(0, e));
            posWS.y += h;
            nrmWS = normalize(vec3(h - hx, e, h - hz));
        }
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            #define NPR_VARYINGS
            #include "Ch15/NPRCommon.glsl"

            #ifdef VERTEX
            void main()
            {
                vec3 posWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nrmWS = TransformObjectToWorldNormal(normalOS);
                Deform(posWS, nrmWS);
                positionWS = posWS;
                normalWS = nrmWS;
                gl_Position = TransformWorldToHClip(posWS);
                uv = texcoord0.xy; vertexColor = color; fogFactor = 0.0;
            }
            #endif

            #ifdef FRAGMENT
            vec3 FaceNormal(vec3 posWS)
            {
                // ddx/ddy:相邻像素之间世界坐标的差,两者都躺在三角形平面内,叉乘就是面法线。
                // 同一个 2×2 像素块里共享导数,而三角形内部世界坐标是线性的,所以结果是“整面常量”。
                vec3 n = normalize(cross(dFdy(posWS), dFdx(posWS)));
                // 屏幕 y 轴朝上还是朝下因平台/是否渲染到 RT 而异,叉乘方向可能反过来;
                // 正面一定朝向相机,所以用视线方向把它翻正,最省心
                vec3 V = _WorldSpaceCameraPos - posWS;
                return dot(n, V) < 0.0 ? -n : n;
            }

            vec3 TerrainColor(float y, vec3 N)
            {
                vec3 c = vec3(0.10, 0.32, 0.62);                                           // 水
                c = mix(c, vec3(0.80, 0.68, 0.42), step(_WaterLevel + 0.02, y));           // 沙
                c = mix(c, vec3(0.30, 0.55, 0.18), step(_WaterLevel + 0.25, y));           // 草
                c = mix(c, vec3(0.42, 0.37, 0.33), step(0.3, y) * step(N.y, 0.9));        // 陡坡变岩石
                c = mix(c, vec3(0.92, 0.93, 0.96), step(1.3, y));                          // 雪
                // 上面这些常量是按“显示器上看到的颜色”(sRGB)挑的,线性空间工程里要先转成线性值
                return SRGBToLinear(c);
            }

            vec4 frag()
            {
                vec3 N = _Flat > 0.5 ? FaceNormal(positionWS) : normalize(normalWS);
                NprLight L = GetNprLight(positionWS, N);
                vec3 albedo = _Terrain > 0.5 ? TerrainColor(positionWS.y, N) : _BaseColor.rgb;
                vec3 col = albedo * (saturate(L.ndotl) * L.shadow * L.color + SampleSH(N));
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #include "Ch15/NPRCommon.glsl"
            #ifdef VERTEX
            void main()
            {
                vec3 posWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nrmWS = TransformObjectToWorldNormal(normalOS);
                Deform(posWS, nrmWS);   // 阴影要和颜色 Pass 用同样的变形
                vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(posWS, nrmWS, _LightDirection));
                positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);   // WebGL 不反转深度
                gl_Position = positionCS;
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = NprNullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #include "Ch15/NPRCommon.glsl"
            #ifdef VERTEX
            void main()
            {
                vec3 posWS = TransformObjectToWorld(positionOS.xyz);
                vec3 nrmWS = TransformObjectToWorldNormal(normalOS);
                Deform(posWS, nrmWS);
                gl_Position = TransformWorldToHClip(posWS);
            }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = vec4(gl_FragCoord.z); }
            #endif
            ENDGLSL
        }
    }
}
图 15-16同一块地形网格、同一个 Shader。左:插值的平滑法线;右:片元里用 ddx/ddy 求出的面法线,每个三角形一种明暗,右上角的二十面体球也变成了多面体
常见坑① 导数是在 2×2 像素块里算的,三角形边缘的像素块可能有一部分像素落在三角形外(GPU 会为它们执行“辅助像素”以便求导),它们的世界坐标是外插出来的,但仍在同一平面上,所以面法线依旧正确。② 在移动端,如果 positionWS 用 half 精度插值,远离原点的物体世界坐标精度不够,ddx 的结果会抖动成噪点。面法线计算一定要用 float,必要时改用相对相机的坐标(positionWS − _WorldSpaceCameraPos)求导。③ 用面法线时,顶点法线就只在阴影偏移(ShadowCaster 的 Normal Bias)里起作用了,两者不一致可能导致少量阴影痤疮,适当调大 Depth Bias 即可。
✓ 适合 ddx/ddy
运行时决定是否 flat(切换风格、调试);不想增加顶点数;程序化生成或顶点动画的网格(变形后的面法线自动正确)。
✗ 更适合拆网格
需要在顶点阶段就用到面法线(例如沿面法线外扩);目标平台对片元导数精度不友好;需要逐面的顶点色或 UV。