案例 16.4Alpha Clip 与 Alpha to Coverage进阶
目标:比较铁丝网这类“细镂空”材质的三种做法,理解 Alpha to Coverage 的原理以及 alpha 锐化。
原理:Alpha Clip(Alpha Test)在片元里 clip(alpha - cutoff),低于阈值的像素直接丢弃。它不需要排序、能写深度、能投射正确的阴影,是树叶、草、栅栏的标准做法。缺点有两个:边缘是“要么全有要么全无”,锯齿明显,而且 MSAA 救不了它——MSAA 只对几何边缘多采样,片元着色器每像素只执行一次,clip 的结果对所有子样本都一样;第二个缺点是 mip:远处的 mip 层把细线和透明背景平均成很低的 alpha,全部低于阈值,铁丝网在远处就“断”成了零星的点。
Alpha to Coverage(A2C)利用 MSAA 的子样本:开启后,GPU 把片元输出的 alpha 换算成一个覆盖掩码——4× MSAA 下 alpha = 0.5 大约覆盖 2 个子样本——只把颜色写进被覆盖的子样本。MSAA 解析(Resolve)时求平均,就得到了 4 级的半透明过渡,而且不需要排序、照样写深度。
但直接把贴图 alpha 交给 A2C 会发虚:贴图边缘的 alpha 过渡可能跨好几个像素,放大看是一片半透明的雾。解决办法是锐化 alpha:以 cutoff 为中心,用 fwidth 把过渡压缩到约一个像素宽:
这其实就是案例 15.3 网点抗锯齿的同一个技巧:在阈值处做一个像素宽的 smoothstep。URP 14 自己就是这么做的——Lit 材质在“不透明 + Alpha Clipping”时会自动打开 AlphaToMask,而 AlphaClip() 函数在 MSAA 可用时返回锐化后的 alpha:
// 摘自 URP 14 ShaderLibrary/ShaderVariablesFunctions.hlsl half SharpenAlphaStrict(half alpha, half alphaClipTreshold) { half dAlpha = fwidth(alpha); return saturate(((alpha - alphaClipTreshold - (0.5 * dAlpha)) / max(dAlpha, 0.0001)) + 1.0); }
“MSAA 可用”由一个全局变量 _AlphaToMaskAvailable 表示,DrawObjectsPass 只在相机目标多于 1 个采样、且正在画不透明物体时把它设为 1。也就是说:没开 MSAA 时,同一个材质自动退化为普通的 Alpha Clip,不会出现“A2C 没有子样本可用、结果全透明”的事故。
完整代码:
// 镂空材质的三种做法: // Alpha Clip:clip() 硬裁剪,边缘锯齿,远处细线会因 mip 平均而消失; // Alpha to Coverage(A2C):把 alpha 转成 MSAA 的覆盖掩码,边缘有 MSAA 级别的过渡(需要开 MSAA); // A2C + 锐化:用 fwidth 把 alpha 重新拉成“一个像素宽的过渡”,既保住细线又不发虚。 Shader "Cookbook/Ch16/Cutout" { Properties { [MainTexture] _BaseMap ("Base Map", 2D) = "white" {} [MainColor] _BaseColor ("Color", Color) = (1, 1, 1, 1) _Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5 [Enum(Off, 0, On, 1)] _AlphaToMask ("Alpha To Coverage", Float) = 0 [Toggle] _Sharpen ("Sharpen Alpha (for A2C)", Float) = 0 [Toggle] _MipScale ("Preserve Coverage Over Mips", Float) = 0 } SubShader { Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half _Cutoff; float _AlphaToMask, _Sharpen, _MipScale; float4 _BaseMap_TexelSize; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); half CutoutAlpha(float2 uv) { half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) float2 texel = uv * _BaseMap_TexelSize.zw; float2 dx = ddx(texel), dy = ddy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; } ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull Off AlphaToMask [_AlphaToMask] HLSLPROGRAM #pragma vertex Ch16Vert #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 half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { float2 uv = TRANSFORM_TEX(i.uv, _BaseMap); half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv); half a = CutoutAlpha(uv); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { clip(a - _Cutoff); // 经典 Alpha Test a = 1; } float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0); half3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, i.positionWS, 0.2h); return half4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct V2F { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; V2F vert(Attributes v) { V2F o; o.positionCS = Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS); o.uv = TRANSFORM_TEX(v.uv, _BaseMap); return o; } // 阴影贴图没有 MSAA,只能用硬裁剪 half4 frag(V2F i) : SV_Target { clip(CutoutAlpha(i.uv) - _Cutoff); return 0; } ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct V2F { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; V2F vert(Attributes v) { V2F o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.uv = TRANSFORM_TEX(v.uv, _BaseMap); return o; } half4 frag(V2F i) : SV_Target { clip(CutoutAlpha(i.uv) - _Cutoff); return 0; } ENDHLSL } } }
// 镂空材质的三种做法(GLSL 版): // Alpha Clip:clip() 硬裁剪,边缘锯齿,远处细线会因 mip 平均而消失; // Alpha to Coverage(A2C):把 alpha 转成 MSAA 的覆盖掩码,边缘有 MSAA 级别的过渡(需要开 MSAA); // A2C + 锐化:用 fwidth 把 alpha 重新拉成“一个像素宽的过渡”,既保住细线又不发虚。 Shader "Cookbook/Ch16/Cutout" { SubShader { Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform float _Cutoff; uniform float _AlphaToMask, _Sharpen, _MipScale; uniform vec4 _BaseMap_TexelSize; uniform sampler2D _BaseMap; #ifdef FRAGMENT float CutoutAlpha(vec2 uv) { float a = texture(_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) vec2 texel = uv * _BaseMap_TexelSize.zw; vec2 dx = dFdx(texel), dy = dFdy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; } #endif ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull Off AlphaToMask [_AlphaToMask] GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { vec2 uvT = TransformTex(uv, _BaseMap_ST); vec4 tex = texture(_BaseMap, uvT); float a = CutoutAlpha(uvT); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { if (a - _Cutoff < 0.0) discard; // 经典 Alpha Test a = 1.0; } vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0); vec3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, positionWS, 0.2); fragColor = vec4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off GLSLPROGRAM VARYING vec2 uvS; #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); uvS = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT // 阴影贴图没有 MSAA,只能用硬裁剪 void main() { if (CutoutAlpha(uvS) - _Cutoff < 0.0) discard; fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Off GLSLPROGRAM VARYING vec2 uvS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); uvS = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT void main() { if (CutoutAlpha(uvS) - _Cutoff < 0.0) discard; fragColor = vec4(gl_FragCoord.z); } #endif ENDGLSL } } }
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { float2 uv = TRANSFORM_TEX(i.uv, _BaseMap); half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv); half a = CutoutAlpha(uv); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { clip(a - _Cutoff); // 经典 Alpha Test a = 1; } float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0); half3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, i.positionWS, 0.2h); return half4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 }
void main() { vec2 uvT = TransformTex(uv, _BaseMap_ST); vec4 tex = texture(_BaseMap, uvT); float a = CutoutAlpha(uvT); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { if (a - _Cutoff < 0.0) discard; // 经典 Alpha Test a = 1.0; } vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0); vec3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, positionWS, 0.2); fragColor = vec4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 }
远处细线消失的问题,本例用了一个经验做法:根据 UV 导数估算当前 mip 级别,按级别放大 alpha(每级约 30%),抵消平均带来的损失:
half CutoutAlpha(float2 uv) { half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) float2 texel = uv * _BaseMap_TexelSize.zw; float2 dx = ddx(texel), dy = ddy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; }
float CutoutAlpha(vec2 uv) { float a = texture(_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) vec2 texel = uv * _BaseMap_TexelSize.zw; vec2 dx = dFdx(texel), dy = dFdy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; }
更严谨的做法是在导入/生成贴图时,为每一级 mip 单独缩放 alpha,使“alpha > cutoff 的像素比例”与 mip 0 保持一致(Unity 的纹理导入设置里有 Mip Maps Preserve Coverage 选项,做的就是这件事)。运行时估算的好处是不依赖导入设置,缺点是系数要凭经验调。
