GameTech/URP 渲染实战

案例 15.6Matcap 风格化入门

目标:用一张“材质球照片”决定物体的全部外观;程序化生成几种风格的 Matcap,并修正 Matcap 在画面边缘的透视畸变。

原理:Matcap(Material Capture,也叫 Lit Sphere)的想法是:一个球面上包含了所有朝向的法线。如果把“某种材质在某种光照下的球”拍成一张图,那么任何物体上的任意一点,只要找到球上法线相同的那一点,取它的颜色就行了。具体到 Shader:把法线变换到观察空间,正对相机的半球投影到圆盘上,法线的 xy 分量就是圆盘上的坐标:

uv=0.5+0.5·Nview.xy

第 8 章把 Matcap 当作环境光照的一个对照;这一章关注它在风格化里的用法——陶土、黏土动画、卡通、金属漆,换一张图就换一种风格,而且一次采样就完成全部“光照”,在移动端几乎是最便宜的着色方式。

程序化生成 Matcap

既然 Matcap 是“球面上每个法线对应的颜色”,生成它只需要反过来:对圆盘内的每个像素反推出法线 n = (x, y, √(1 − x² − y²)),然后用任意着色函数算颜色:

Assets/Cookbook/Ch15/MatcapGenerator.cs(节选:gen)
public static Texture2D Build(int size, Func<Vector3, Color> shade, string name = "MatCap")
{
    var tex = new Texture2D(size, size, TextureFormat.RGBA32, true, false)
    { name = name, wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Trilinear };
    var px = new Color[size * size];
    for (int y = 0; y < size; y++)
        for (int x = 0; x < size; x++)
        {
            float u = (x + 0.5f) / size * 2f - 1f, v = (y + 0.5f) / size * 2f - 1f;
            float r2 = u * u + v * v;
            // 圆盘外的像素也给一个合理值(取边缘法线),防止双线性/mip 把黑边混进来
            float k = r2 > 1f ? 1f / Mathf.Sqrt(r2) : 1f;
            var n = new Vector3(u * k, v * k, Mathf.Sqrt(Mathf.Max(0f, 1f - r2 * k * k)));
            px[y * size + x] = shade(n);
        }
    tex.SetPixels(px); tex.Apply(true);
    return tex;
}

圆盘外的像素也给了“边缘法线”的颜色,因为双线性过滤和 mip 会采到圆盘外一点点,如果那里是黑色,物体轮廓就会出现一圈黑边。MatcapGenerator 里写了三种着色函数:陶土(柔和漫反射 + 冷色底部反光)、卡通(三阶色 + 硬高光 + 亮边)、金属漆(用反射向量在“天空/地平线/地面”之间取色,再染成金色)。你可以把任何第 5、9 章学过的光照模型写成这样的函数,“冻结”进一张贴图。

透视修正

朴素的 Matcap 有一个缺点:它假设相机是正交的。在透视相机下,画面边缘的物体,相机是“斜着”看它的,可是查表用的仍然是观察空间的法线——于是边缘的球看起来像被拧了一下,高光和地平线位置都不对。修正办法是:对每个像素,以“从这个点指向相机”的方向 V 作为局部的 z 轴,重新构造 x、y 轴,再把法线投影到这组轴上。这等于把每个像素都当成“正对相机的那一点”来查表。

相机 观察方向 −z 朴素:仍以 −z 为“正对” 修正:以 V 为“正对” 画面中心两者一致 X′ = normalize(Up − V(V·Up)) × V Y′ = normalize(Up − V(V·Up)) uv = 0.5 + 0.49(N·X′, N·Y′)
图 15-12Matcap 的透视修正:边缘物体用“指向相机的方向 V”作为查表的正前方
Assets/Cookbook/Ch15/MatcapStylized.shader(节选:matcap-uv)
float2 MatcapUV(float3 positionWS, float3 normalWS)
{
    float3 normalVS = normalize(TransformWorldToViewDir(normalWS));
    // 朴素做法:直接取观察空间法线的 xy
    float2 naive = normalVS.xy;

    // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
    // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
    float3 V = normalize(-TransformWorldToView(positionWS));   // 观察空间里相机在原点
    float3 up = normalize(float3(0, 1, 0) - V * V.y);          // 把世界“上”投影到垂直于 V 的平面
    float3 right = cross(up, V);
    float2 corrected = float2(dot(normalVS, right), dot(normalVS, up));

    float2 n = lerp(naive, corrected, _PerspectiveFix);
    return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
}
vec2 MatcapUV(vec3 posWS, vec3 nrmWS)
{
    vec3 normalVS = normalize(TransformWorldToViewDir(nrmWS));
    // 朴素做法:直接取观察空间法线的 xy
    vec2 naive = normalVS.xy;

    // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
    // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
    vec3 V = normalize(-TransformWorldToView(posWS));        // 观察空间里相机在原点
    vec3 up = normalize(vec3(0, 1, 0) - V * V.y);            // 把世界“上”投影到垂直于 V 的平面
    vec3 right = cross(up, V);
    vec2 corrected = vec2(dot(normalVS, right), dot(normalVS, up));

    vec2 n = mix(naive, corrected, _PerspectiveFix);
    return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
}

Unity 的观察空间是右手系、相机朝 −z 看,所以观察空间里的点坐标取反就是“点 → 相机”的方向。up 是把世界“上”方向(这里直接用观察空间的 (0,1,0))投影到垂直于 V 的平面上再归一化,right = cross(up, V)。在画面正中,V = (0,0,1),up = (0,1,0),right = (1,0,0),修正版和朴素版完全一致。缩放系数用 0.49 而不是 0.5,是为了离圆盘边缘留一点余量。

完整代码:

Assets/Cookbook/Ch15/MatcapStylized.shader
// Matcap(材质捕获):把“一个球在某种光照下的样子”存成贴图,用观察空间法线去查表。
// 修正了透视畸变(以视线方向为基准建立局部坐标系),并可选地叠加场景阴影。
Shader "Cookbook/Ch15/MatcapStylized"
{
    Properties
    {
        [NoScaleOffset] _MatCap ("MatCap", 2D) = "gray" {}
        _BaseColor ("Tint", Color) = (1, 1, 1, 1)
        [Toggle] _PerspectiveFix ("Perspective Correction", Float) = 1
        _ShadowStrength ("Receive Shadow Strength", Range(0, 1)) = 0.5
    }
    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 _PerspectiveFix;
            half  _ShadowStrength;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma vertex NprVert
            #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"

            TEXTURE2D(_MatCap); SAMPLER(sampler_MatCap);

            float2 MatcapUV(float3 positionWS, float3 normalWS)
            {
                float3 normalVS = normalize(TransformWorldToViewDir(normalWS));
                // 朴素做法:直接取观察空间法线的 xy
                float2 naive = normalVS.xy;

                // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
                // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
                float3 V = normalize(-TransformWorldToView(positionWS));   // 观察空间里相机在原点
                float3 up = normalize(float3(0, 1, 0) - V * V.y);          // 把世界“上”投影到垂直于 V 的平面
                float3 right = cross(up, V);
                float2 corrected = float2(dot(normalVS, right), dot(normalVS, up));

                float2 n = lerp(naive, corrected, _PerspectiveFix);
                return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
            }

            half4 frag(NprVaryings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                half3 col = SAMPLE_TEXTURE2D(_MatCap, sampler_MatCap, MatcapUV(i.positionWS, N)).rgb * _BaseColor.rgb;
                // Matcap 不认识场景里的灯,阴影只能“乘”上去:这是风格化里常见的折中
                NprLight L = GetNprLight(i.positionWS, N);
                col *= lerp(1.0h, L.shadow, _ShadowStrength);
                return half4(col, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex NprShadowVert
            #pragma fragment NprNullFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex NprDepthVert
            #pragma fragment NprNullFrag
            #include "Assets/Cookbook/Ch15/NPRCommon.hlsl"
            ENDHLSL
        }
    }
}
// Matcap(材质捕获):把“一个球在某种光照下的样子”存成贴图,用观察空间法线去查表。(GLSL 版,对应 Ch15/MatcapStylized.shader)
// 修正了透视畸变(以视线方向为基准建立局部坐标系),并可选地叠加场景阴影。
Shader "Cookbook/Ch15/MatcapStylized"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4 _BaseColor;
        uniform float _PerspectiveFix;
        uniform float _ShadowStrength;
        ENDGLSL

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

            uniform sampler2D _MatCap;

            #ifdef VERTEX
            void main() { NprVert(); }
            #endif

            #ifdef FRAGMENT
            vec2 MatcapUV(vec3 posWS, vec3 nrmWS)
            {
                vec3 normalVS = normalize(TransformWorldToViewDir(nrmWS));
                // 朴素做法:直接取观察空间法线的 xy
                vec2 naive = normalVS.xy;

                // 修正:以“从该点指向相机”的方向 V 为 z 轴,重新构造 x/y 轴,
                // 等于把每个像素都当成“正对相机”的那一点来查表,画面边缘不再被拉扯
                vec3 V = normalize(-TransformWorldToView(posWS));        // 观察空间里相机在原点
                vec3 up = normalize(vec3(0, 1, 0) - V * V.y);            // 把世界“上”投影到垂直于 V 的平面
                vec3 right = cross(up, V);
                vec2 corrected = vec2(dot(normalVS, right), dot(normalVS, up));

                vec2 n = mix(naive, corrected, _PerspectiveFix);
                return n * 0.49 + 0.5;    // 0.49 而非 0.5:避开贴图最外圈的边缘像素
            }

            vec4 frag()
            {
                vec3 N = normalize(normalWS);
                vec3 col = texture(_MatCap, MatcapUV(positionWS, N)).rgb * _BaseColor.rgb;
                // Matcap 不认识场景里的灯,阴影只能“乘”上去:这是风格化里常见的折中
                NprLight L = GetNprLight(positionWS, N);
                col *= mix(1.0, L.shadow, _ShadowStrength);
                return vec4(col, 1.0);
            }
            void main() { fragColor = frag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #define NPR_SHADOW_PASS
            #include "Ch15/NPRCommon.glsl"
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #define NPR_DEPTH_PASS
            #include "Ch15/NPRCommon.glsl"
            ENDGLSL
        }
    }
}
图 15-13同一个 Shader、三张程序生成的 Matcap(左上角为贴图本身):陶土、卡通、金色金属漆。地面阴影通过乘法叠加到 Matcap 上
图 15-1490° 广角下的 15 个球。左:朴素 Matcap,边缘的球的地平线和高光被“拧”向画面中心;右:透视修正后,每个球看起来都像是被正对着观察
常见坑① Matcap 的光照是“锁在相机上”的:相机绕物体转一圈,高光永远在同一个位置。这在风格化里通常可以接受,但对于需要“光从窗户照进来”的场景,就必须配合真实光照。② Matcap 不认识场景里的灯,阴影只能像本例这样乘上去,这会让暗部发脏,可以改成 lerp(col, col * shadowColor, 1 - shadow) 指定一个阴影色。③ 叠加法线贴图时,必须先用 TBN 把切线空间法线转到世界/观察空间,再查 Matcap,否则凹凸方向全乱。