案例 15.6Matcap 风格化入门
目标:用一张“材质球照片”决定物体的全部外观;程序化生成几种风格的 Matcap,并修正 Matcap 在画面边缘的透视畸变。
原理:Matcap(Material Capture,也叫 Lit Sphere)的想法是:一个球面上包含了所有朝向的法线。如果把“某种材质在某种光照下的球”拍成一张图,那么任何物体上的任意一点,只要找到球上法线相同的那一点,取它的颜色就行了。具体到 Shader:把法线变换到观察空间,正对相机的半球投影到圆盘上,法线的 xy 分量就是圆盘上的坐标:
第 8 章把 Matcap 当作环境光照的一个对照;这一章关注它在风格化里的用法——陶土、黏土动画、卡通、金属漆,换一张图就换一种风格,而且一次采样就完成全部“光照”,在移动端几乎是最便宜的着色方式。
程序化生成 Matcap
既然 Matcap 是“球面上每个法线对应的颜色”,生成它只需要反过来:对圆盘内的每个像素反推出法线 n = (x, y, √(1 − x² − y²)),然后用任意着色函数算颜色:
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 轴,再把法线投影到这组轴上。这等于把每个像素都当成“正对相机的那一点”来查表。
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,是为了离圆盘边缘留一点余量。
完整代码:
// 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 } } }


lerp(col, col * shadowColor, 1 - shadow) 指定一个阴影色。③ 叠加法线贴图时,必须先用 TBN 把切线空间法线转到世界/观察空间,再查 Matcap,否则凹凸方向全乱。