案例 14.5裁剪空间等宽描边进阶
目标:实现背面外扩描边,比较物体空间外扩与裁剪空间外扩的线宽差异,并理解为什么一个 Pass 用 SRPDefaultUnlit 就能被 URP 自动绘制。
描边 Pass 放在哪
URP 的不透明绘制(DrawObjectsPass)会收集三种 LightMode 的 Pass:UniversalForward、UniversalForwardOnly 和 SRPDefaultUnlit(没有写 LightMode 的 Pass 也视为后者)。所以只要把描边 Pass 的 LightMode 设为 SRPDefaultUnlit,它就会和着色 Pass 一起被画出来,不需要任何 Renderer Feature。限制是:一个 Shader 里同一个 LightMode 只会被画一次,所以只能有一个描边 Pass;如果需要更多 Pass(例如额外的发光层),就得用 Renderer Feature 的 RenderObjects 或自定义 ShaderTagId(第 25 章)。
顺便说一下这种多 Pass 写法与 SRP Batcher 的关系:两个 Pass 用同一个 UnityPerMaterial 常量缓冲区(描边颜色、线宽也放在里面),SRP Batcher 依然兼容;但每个物体会多一次 Draw Call,场景里描边物体很多时要注意。
Pass { Name "Outline" // URP 的不透明队列会画 UniversalForward 和 SRPDefaultUnlit 两种 LightMode, // 所以第二个 Pass 用 SRPDefaultUnlit 就能自动被画到,不需要 Renderer Feature Tags { "LightMode"="SRPDefaultUnlit" } Cull Front // 只画背面 ZWrite On HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local_vertex _OUTLINESPACE_OBJECT _OUTLINESPACE_CLIP #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR #pragma multi_compile_fog #pragma multi_compile_instancing #include "Assets/Cookbook/Ch14/ToonCommon.hlsl" struct OutlineVaryings { float4 positionCS : SV_POSITION; float fogFactor : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; // 取“用来外扩的法线”:原始法线 / 烘焙在切线里的平滑法线 / 烘焙在顶点色里的切线空间平滑法线 float3 GetOutlineNormalOS(ToonAttributes v) { #if defined(_OUTLINENORMAL_TANGENT) return v.tangentOS.xyz; // 物体空间平滑法线 #elif defined(_OUTLINENORMAL_VERTEXCOLOR) float3 nTS = v.color.rgb * 2.0 - 1.0; // 切线空间平滑法线 float3 bitangentOS = cross(v.normalOS, v.tangentOS.xyz) * v.tangentOS.w; return normalize(nTS.x * v.tangentOS.xyz + nTS.y * bitangentOS + nTS.z * v.normalOS); #else return v.normalOS; #endif } OutlineVaryings vert(ToonAttributes v) { OutlineVaryings o; UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, o); float3 n = GetOutlineNormalOS(v); #if defined(_OUTLINESPACE_OBJECT) o.positionCS = OutlinePositionCS_ObjectSpace(v.positionOS.xyz, n, _OutlineWidthOS); #else o.positionCS = OutlinePositionCS(v.positionOS.xyz, n, _OutlineWidth); #endif o.fogFactor = ComputeFogFactor(o.positionCS.z); return o; } half4 frag(OutlineVaryings i) : SV_Target { return half4(MixFog(_OutlineColor.rgb, i.fogFactor), 1); } ENDHLSL }
Pass { Name "Outline" // URP 的不透明队列会画 UniversalForward 和 SRPDefaultUnlit 两种 LightMode, // 所以第二个 Pass 用 SRPDefaultUnlit 就能自动被画到,不需要 Renderer Feature Tags { "LightMode"="SRPDefaultUnlit" } Cull Front // 只画背面 ZWrite On GLSLPROGRAM #pragma shader_feature_local_vertex _OUTLINESPACE_OBJECT _OUTLINESPACE_CLIP #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR #include "Ch14/ToonCommon.glsl" VARYING float fogFactor; #ifdef VERTEX // 取“用来外扩的法线”:原始法线 / 烘焙在切线里的平滑法线 / 烘焙在顶点色里的切线空间平滑法线 vec3 GetOutlineNormalOS() { #if defined(_OUTLINENORMAL_TANGENT) return tangentOS.xyz; // 物体空间平滑法线 #elif defined(_OUTLINENORMAL_VERTEXCOLOR) vec3 nTS = color.rgb * 2.0 - 1.0; // 切线空间平滑法线 vec3 bitangentOS = cross(normalOS, tangentOS.xyz) * tangentOS.w; return normalize(nTS.x * tangentOS.xyz + nTS.y * bitangentOS + nTS.z * normalOS); #else return normalOS; #endif } void main() { vec3 n = GetOutlineNormalOS(); #if defined(_OUTLINESPACE_OBJECT) gl_Position = OutlinePositionCS_ObjectSpace(positionOS.xyz, n, _OutlineWidthOS); #else gl_Position = OutlinePositionCS(positionOS.xyz, n, _OutlineWidth); #endif fogFactor = ComputeFogFactor(gl_Position.z); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(MixFog(_OutlineColor.rgb, fogFactor), 1.0); } #endif ENDGLSL }
外扩的数学
最直观的写法是在物体空间里推:positionOS + normalOS * width。问题是这个“宽度”是世界单位,经过透视投影后,近处的线粗、远处的线细,物体缩放后线宽也跟着变——这有时是想要的(近大远小更有空间感),但大多数卡通项目希望线宽在屏幕上保持恒定。
恒定像素宽度的做法是在裁剪空间里推。顶点经过 MVP 变换后得到 ,之后 GPU 会做透视除法 。NDC 的 [−1, 1] 对应整个屏幕,所以一个像素在 NDC 里的大小是 。我们想在 NDC 里偏移 (d 为像素数),那么在除法之前就要偏移这个量乘以 w:
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。 // normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素) float4 OutlinePositionCS(float3 positionOS, float3 normalOS, float widthPx) { float4 positionCS = TransformObjectToHClip(positionOS); float3 normalWS = TransformObjectToWorldNormal(normalOS); float3 normalCS = TransformWorldToHClipDir(normalWS, true); // 方向向量只受 VP 的 3×3 部分影响 // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比), // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致 float2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6); // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法 float2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy; positionCS.xy += offset * positionCS.w; return positionCS; } // 对照组:物体空间外扩(线宽随距离变化,近粗远细) float4 OutlinePositionCS_ObjectSpace(float3 positionOS, float3 normalOS, float width) { return TransformObjectToHClip(positionOS + normalize(normalOS) * width); }
// 描边外扩:在裁剪空间沿法线方向推开固定的像素宽度。 // normalOS:用于外扩的法线(可以是平滑后的法线);widthPx:线宽(像素) vec4 OutlinePositionCS(vec3 posOS, vec3 nrmOS, float widthPx) { vec4 positionCS = TransformObjectToHClip(posOS); vec3 nWS = TransformObjectToWorldNormal(nrmOS); vec3 normalCS = mat3(unity_MatrixVP) * nWS; // 方向向量只受 VP 的 3×3 部分影响(= TransformWorldToHClipDir) // 先把方向换到“像素空间”再归一化:NDC 的 x、y 一个单位对应的像素数不同(宽高比), // 直接 normalize(normalCS.xy) 会让横向和纵向的线宽不一致 vec2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6); // NDC 的 [-1,1] 对应整个屏幕,所以 1 像素 = 2 / 屏幕尺寸;乘 w 是为了抵消之后的透视除法 vec2 offset = dirPx * widthPx * 2.0 / _ScreenParams.xy; positionCS.xy += offset * positionCS.w; return positionCS; } // 对照组:物体空间外扩(线宽随距离变化,近粗远细) vec4 OutlinePositionCS_ObjectSpace(vec3 posOS, vec3 nrmOS, float width) { return TransformObjectToHClip(posOS + normalize(nrmOS) * width); }
方向 来自法线在裁剪空间里的 xy 分量。这里有个常被忽略的细节:NDC 的 x 和 y 方向“一个单位等于多少像素”不同(宽高比),如果直接 normalize(normalCS.xy),宽屏上横向的线会比纵向的粗。正确的做法是先乘屏幕尺寸换到像素空间再归一化,最后再除回去。另外,法线是方向向量,变换到裁剪空间只用 VP 矩阵的 3×3 部分,对应 URP 的 TransformWorldToHClipDir。

clamp(distance / 参考距离, 0.5, 1.5)),或者按 FOV 修正。顶点色的 A 通道常用来做“线宽遮罩”:在鼻尖、指尖、衣服褶皱内侧把线宽刷成 0,避免内部出现多余的线。描边颜色也不一定是黑色,用固有色压暗、偏色后的颜色,画面会柔和很多。