案例 15.7低多边形 flat shading:用 ddx/ddy 求面法线入门
目标:不修改网格,让光滑物体呈现出“一面一色”的低多边形(Low Poly)风格。
原理:常规做法是把网格拆开,让每个三角形独占三个顶点、写入相同的面法线。这会把顶点数变成三角形数的三倍,而且必须在建模或导入阶段处理。另一种做法完全在片元着色器里完成:GPU 以 2×2 的像素块(Quad)为单位执行片元着色器,ddx(v) 返回同一块中右邻像素与当前像素的 v 之差,ddy(v) 返回下(或上)邻像素之差。对世界坐标求导:
两个差向量都是“沿屏幕横向/纵向走一个像素时,世界坐标移动了多少”,它们都躺在三角形所在的平面内,叉乘就得到平面的法线。而世界坐标在一个三角形内部是线性插值的(透视校正后依然在同一平面上),所以无论在三角形哪个位置求,方向都一样——整面常量,正是 flat shading 需要的。
叉乘的方向是这里唯一的坑。ddy 是“屏幕 y 增大的方向”,而屏幕 y 轴到底朝上还是朝下,取决于平台(D3D/Metal 的像素坐标原点在左上,OpenGL 在左下)以及当前是否在往 RT 里渲染(URP 会翻转投影矩阵,见第 1 章的 _ProjectionParams.x)。与其逐一判断,不如利用一个简单的事实:我们看到的正面,法线一定朝向相机。算出叉乘后,和视线方向点乘,如果是负的就翻过来:
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 会自动转换,写死的常量不会。
// 低多边形(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 } } }

positionWS 用 half 精度插值,远离原点的物体世界坐标精度不够,ddx 的结果会抖动成噪点。面法线计算一定要用 float,必要时改用相对相机的坐标(positionWS − _WorldSpaceCameraPos)求导。③ 用面法线时,顶点法线就只在阴影偏移(ShadowCaster 的 Normal Bias)里起作用了,两者不一致可能导致少量阴影痤疮,适当调大 Depth Bias 即可。