GameTech/URP 渲染实战

案例 5.8逐顶点与逐像素光照入门

目标:把同一套 Blinn-Phong 分别放在顶点着色器和片元着色器里计算,直观看到 Gouraud 着色的问题,并知道它在今天还有什么用。

两个容易混淆的名字

“Phong”这个名字在图形学里指两样东西:一是案例 5.4 的 Phong 反射模型(怎么算一个点的亮度),二是 Phong 着色(Phong Shading,在三角形内部插值法线、逐像素计算光照)。与之对应的是 Gouraud 着色:在顶点上算好光照颜色,再在三角形内部插值颜色。两者可以与任何反射模型组合,比如“用 Gouraud 着色计算 Blinn-Phong 反射”。

顶点 A顶点 B 三角形内部(屏幕上的一行像素) 逐像素(蓝色实线):插值法线后再算,高光被捕捉 Gouraud(橙色虚线):只在顶点处求值,两端都暗,插值后也暗 亮暗
图 5-19沿三角形一条扫描线的高光亮度。高光峰值落在两个顶点之间时,Gouraud 着色在顶点处采不到它,插值后高光就“消失”了;反过来,若峰值恰好落在顶点上,又会被插值成一个多边形形状的亮斑

代码

Assets/Cookbook/Ch05/Gouraud.shader(节选:shade)
half3 ShadeBlinnPhong(float3 positionWS, float3 N)
{
    float3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light mainLight = GetMainLight();
    float3 L = mainLight.direction;
    half3 radiance = mainLight.color * mainLight.distanceAttenuation;
    float NdotL = dot(N, L);
    float spec = pow(saturate(dot(N, SafeNormalize(L + V))), _Shininess) * step(0.0, NdotL);
    half3 color = (_BaseColor.rgb * saturate(NdotL) + _SpecColor.rgb * spec) * radiance;
    return color + _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
}
vec3 ShadeBlinnPhong(vec3 positionWS, vec3 N)
{
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light mainLight = GetMainLight();
    vec3 L = mainLight.direction;
    vec3 radiance = mainLight.color * mainLight.distanceAttenuation;
    float NdotL = dot(N, L);
    float spec = pow(saturate(dot(N, SafeNormalize(L + V))), _Shininess) * step(0.0, NdotL);
    vec3 color = (_BaseColor.rgb * saturate(NdotL) + _SpecColor.rgb * spec) * radiance;
    return color + _BaseColor.rgb * SampleSH(N) * _AmbientStrength;
}
Assets/Cookbook/Ch05/Gouraud.shader(节选:vertfrag)
Varyings vert(Attributes input)
{
    Varyings output;
    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
    output.positionCS = pos.positionCS;
#if defined(_PER_VERTEX)
    output.color = ShadeBlinnPhong(pos.positionWS, normalWS);
#else
    output.positionWS = pos.positionWS;
    output.normalWS = normalWS;
#endif
    return output;
}

half4 frag(Varyings input) : SV_Target
{
#if defined(_PER_VERTEX)
    return half4(input.color, 1);
#else
    return half4(ShadeBlinnPhong(input.positionWS, normalize(input.normalWS)), 1);
#endif
}
#ifdef VERTEX
void main()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    vec3 nWS = TransformObjectToWorldNormal(normalOS);
    gl_Position = pos.positionCS;
#if defined(_PER_VERTEX)
    vertexColor = ShadeBlinnPhong(pos.positionWS, nWS);
#else
    positionWS = pos.positionWS;
    normalWS = nWS;
#endif
}
#endif

#ifdef FRAGMENT
void main()
{
#if defined(_PER_VERTEX)
    fragColor = vec4(vertexColor, 1.0);
#else
    fragColor = vec4(ShadeBlinnPhong(positionWS, normalize(normalWS)), 1.0);
#endif
}
#endif

光照函数 ShadeBlinnPhong 只写一份,用 _PER_VERTEX 关键字决定在哪个阶段调用。注意 Varyings 结构体也随之变化:逐顶点时只需要传一个颜色,逐像素时要传世界坐标和法线。这里用的是 shader_feature_local(不带 _fragment 后缀),因为顶点和片元两个阶段都要看到这个关键字。

图 5-20左:低模球(14×9 段)+ 逐顶点,高光变成多边形的星形亮斑,轮廓上的折线也很明显;中:同一个低模球 + 逐像素,高光恢复成圆形,但轮廓仍是折线(逐像素救不了几何);右:Unity 默认球(更密)+ 逐顶点,高光依然带着网格的菱形痕迹

逐顶点光照的计算量与顶点数成正比,逐像素与像素数成正比。在早期硬件和今天的低端移动设备上,屏幕像素往往远多于顶点,逐顶点能省下大量计算。它的问题也很明确:任何在一个三角形内部变化剧烈的光照项都会出错,高光首当其冲,聚光灯的光斑边缘、点光源近处的衰减也一样。漫反射和环境光变化平缓,逐顶点计算的误差通常可以接受。

URP 里有两处正是这样用的:

✓ 适合逐顶点
环境光、漫反射、远景物体、大量的小型附加光、雾效因子、顶点密集的模型
✗ 避免逐顶点
高光(尤其是高指数)、聚光灯边缘、近处点光源、低模的大平面、需要法线贴图的表面(法线贴图只能逐像素采样)