URP 渲染实战 / 第 12 章 PBR 进阶材质 / 案例 12.3
案例 12.3拉丝金属与同心圆拉丝进阶
目标:用各向异性 GGX 做出直线拉丝(纽结、圆环)和同心圆拉丝(锅底、旋钮),并处理各向异性下的环境反射。
着色函数只是把 D、V 换成各向异性版本:
Assets/Cookbook/Ch12/Anisotropic.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light) { float3 L = light.direction; float3 H = normalize(m.V + L); float NoL = saturate(dot(m.N, L)); float NoV = max(dot(m.N, m.V), 1e-4); float NoH = saturate(dot(m.N, H)); float VoH = saturate(dot(m.V, H)); float D = AdvD_GGXAniso(dot(m.T, H), dot(m.B, H), NoH, m.at, m.ab); float Vis = AdvV_SmithGGXAniso(dot(m.T, m.V), dot(m.B, m.V), NoV, dot(m.T, L), dot(m.B, L), NoL, m.at, m.ab); float3 F = AdvF_Schlick(m.f0, 1.0, VoH); float3 brdf = m.diffuse * ADV_INV_PI + D * Vis * F; return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation); }
vec3 ShadeOneLight(MaterialCtx m, Light light) { vec3 L = light.direction; vec3 H = normalize(m.V + L); float NoL = saturate(dot(m.N, L)); float NoV = max(dot(m.N, m.V), 1e-4); float NoH = saturate(dot(m.N, H)); float VoH = saturate(dot(m.V, H)); float D = AdvD_GGXAniso(dot(m.T, H), dot(m.B, H), NoH, m.at, m.ab); float Vis = AdvV_SmithGGXAniso(dot(m.T, m.V), dot(m.B, m.V), NoV, dot(m.T, L), dot(m.B, L), NoL, m.at, m.ab); vec3 F = AdvF_Schlick(m.f0, 1.0, VoH); vec3 brdf = m.diffuse * ADV_INV_PI + D * Vis * F; return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation); }
拉丝方向有三种来源,用一个 KeywordEnum 切换:网格切线(纹路沿 U)、网格副切线(纹路沿 V),以及“以 UV 中心为圆心的同心圆”。最后一种在片元里现算:从 UV 中心指向当前点的方向是半径方向,把它旋转 90° 就是圆周方向,再用 T、B 把这个二维方向转到世界空间。拉丝条纹贴图(公共贴图 noise_streak,纹路沿 U)的采样坐标也要跟着改:同心圆模式下只按半径取样,条纹就变成一圈圈的同心圆:
Assets/Cookbook/Ch12/Anisotropic.shader(节选:dir)
// 选择拉丝方向;条纹贴图的纹路沿 U 方向,采样坐标也跟着转 float2 streakUV = i.uv; #if defined(_DIR_BITANGENT) float3 tmp = T; T = B; B = -tmp; streakUV = i.uv.yx; #elif defined(_DIR_RADIAL) // 以 UV 中心为圆心的同心圆拉丝(锅底、CD 纹):切线 = 圆周方向 float2 d = i.uv - 0.5; float2 c = normalize(float2(-d.y, d.x) + 1e-5); T = normalize(c.x * T + c.y * B); B = cross(N, T); streakUV = float2(0.5, length(d) * 2.0); // 条纹只随半径变化 → 同心圆 #endif
// 选择拉丝方向;条纹贴图的纹路沿 U 方向,采样坐标也跟着转 vec2 streakUV = uv; #if defined(_DIR_BITANGENT) vec3 tmp = T; T = B; B = -tmp; streakUV = uv.yx; #elif defined(_DIR_RADIAL) // 以 UV 中心为圆心的同心圆拉丝(锅底、CD 纹):切线 = 圆周方向 vec2 d = uv - 0.5; vec2 c = normalize(vec2(-d.y, d.x) + 1e-5); T = normalize(c.x * T + c.y * B); B = cross(N, T); streakUV = vec2(0.5, length(d) * 2.0); // 条纹只随半径变化 → 同心圆 #endif
环境反射:弯曲法线近似
直接光好办,环境光就麻烦了:反射探针的 mip 链是按各向同性的 GGX 预滤波的,各向异性的波瓣没法从中直接取到。完全正确的做法要做多次采样或预滤波更多维度的数据,实时渲染里普遍采用一个巧妙的近似(Filament、HDRP 都在用):把法线朝“粗糙方向”弯一弯,用弯过的法线算反射向量,再按各向同性的方式采样。
Assets/Cookbook/Ch12/Anisotropic.shader(节选:bent)
// 各向异性的环境反射:没法预滤波,常用“弯曲法线”近似(Filament 的做法) // grooveDir 是光滑的方向(划痕走向);anisoN = V 投影到垂直于它的平面上, // 把法线朝 anisoN 扭一扭,反射就会沿“粗糙方向”被拉长 float3 grooveDir = _Anisotropy >= 0.0 ? T : B; float3 anisoT = cross(grooveDir, V); float3 anisoN = cross(anisoT, grooveDir); float bend = abs(_Anisotropy) * saturate(5.0 * pr) * _AnisoIBL; float3 bentN = normalize(lerp(N, anisoN, bend)); float3 R = reflect(-V, bentN);
// 各向异性的环境反射:没法预滤波,常用“弯曲法线”近似(Filament 的做法) // grooveDir 是光滑的方向(划痕走向);anisoN = V 投影到垂直于它的平面上, // 把法线朝 anisoN 扭一扭,反射就会沿“粗糙方向”被拉长 vec3 grooveDir = _Anisotropy >= 0.0 ? T : B; vec3 anisoT = cross(grooveDir, V); vec3 anisoN = cross(anisoT, grooveDir); float bend = abs(_Anisotropy) * saturate(5.0 * pr) * _AnisoIBL; vec3 bentN = normalize(mix(N, anisoN, bend)); vec3 R = reflect(-V, bentN);
思路是:anisoN 是把视线 V 投影到“垂直于纹路方向的平面”上得到的向量。法线越往它靠,反射向量在粗糙方向上的变化就越大——相当于把各向同性的反射图案沿粗糙方向“抹开”。弯曲量乘了 saturate(5 * 感知粗糙度):非常光滑的表面几乎不存在各向异性,不该弯。

k = 0 时三个光源(主光与两盏点光)各留下一个圆斑;随着 k 增大,圆斑沿管子的圆周方向越拉越长,最后变成一圈圈环绕管子的亮带——正是“垂直于纹路”的方向。


常见坑① 切线必须连续。网格的 UV 接缝两侧切线方向不同,各向异性高光会在接缝处断开;拉丝件要专门展一套“沿纹路方向”的 UV。② 用了法线贴图时,切线也要用法线贴图扰动后的 N 重新正交化,否则 T 不在切平面内。③ 一个物体上 T 方向混乱(例如自动展 UV 的模型)时,各向异性只会产生满屏的噪点,不如直接用各向同性。④ 条纹贴图要开 mip,并尽量用各向异性过滤,远处条纹闪烁会非常明显。