在三维渲染中给复杂模型增加材质细节时,投射贴图是常用手段。无论是用世界坐标三平面投射,还是用某个方向做平面投影,投射边缘区域经常出现法线方向错乱、明暗跳动和表面高度感消失的问题。很多人第一反应是提高贴图分辨率或者切换过滤模式,但真正原因往往在于切线空间与投影坐标没有同步校正。

一、投射边缘变形从哪来
法线贴图并不是直接存储朝向,而是把每个像素的法线扰动编码在切线空间中。切线空间由一个表面点的法线、切线和副切线组成,采样法线贴图后要经过TBN矩阵变换到世界空间或视图空间。如果投射贴图时没有为投影表面重新计算一致的TBN,就会用到错误的切线坐标系。
平面投射通常使用世界坐标的XZ平面或XY平面作为UV来源。以三平面投射为例,当表面法线逐渐从一个主轴转向另一个主轴时,投影面会切换。切换边界上的UV梯度变化剧烈,贴图像素被压缩或拉长,法线扰动也跟着被放大。尤其是在球体、角色背部或地形与墙体交界处,投影方向与表面法线夹角变大,一个像素覆盖的贴图区域远超正常密度,边缘就会出现扭曲。
高度贴图也有类似问题。高度贴图通常用于视差映射,让平面上的纹理在视角变化时产生深浅感。如果边缘UV拉伸,高度贴图采样得到的深度梯度同样不可信,视差偏移量会在边缘跳变,看起来像纹理浮在表面或突然凹陷。
要解决这个问题,不能只靠增加贴图分辨率。分辨率再高,只要投影坐标和切线基准不匹配,边缘变形依然存在。正确思路是先分析投影坐标的导数,修正法线贴图的TBN矩阵,再用高度贴图补偿边缘细节。
二、法线贴图校正:重建TBN与边缘混合
校正法线贴图的第一步是放弃直接使用模型自带的切线。对于投影贴图,应该基于投影坐标在表面上的变化方向重新构造TBN。实际做法是用屏幕空间偏导数或表面梯度计算UV在空间中的变化率,从而得到与当前投影像素对齐的切线和副切线。
下面这段GLSL代码展示如何在片段着色器中根据世界坐标和投影权重重建TBN。它假设主要投影方向来自Y轴正向,并与原始表面TBN进行加权混合。
vec3 ComputeProjectedTBN(vec3 worldPos, vec3 worldNormal, vec3 baseTangent)
{
// 使用平面投影Y轴作为主方向
vec3 projTangent = normalize(cross(vec3(0.0, 1.0, 0.0), worldNormal));
if (length(projTangent) < 0.001)
{
projTangent = vec3(1.0, 0.0, 0.0);
}
vec3 projBitangent = normalize(cross(worldNormal, projTangent));
// 与原始切线插值,避免非边缘区域丢失模型UV信息
float blend = abs(worldNormal.y);
vec3 finalTangent = normalize(mix(baseTangent, projTangent, blend));
vec3 finalBitangent = normalize(cross(worldNormal, finalTangent));
return mat3(finalTangent, finalBitangent, worldNormal);
}
这段代码先把投影主轴的垂直方向与表面法线叉积,得到投影切线,再与模型自带切线做混合。混合权重由法线在投影方向上的分量决定。这样的好处是:在接近水平的面,投影切线主导,避免边缘拉伸;在接近垂直的面,保留原始UV切线,不破坏正常纹理方向。
另一个关键点是对法线贴图采样结果进行边缘去重。三平面投射会产生三个方向的重叠区域,如果直接相加会过亮。常见的做法是用法线的绝对值作为权重,优先选择与表面对齐最好的投影面。比如在边缘处让两个投影面的法线按权重归一化混合,这样能明显减轻接缝和方向反转。
还需要处理法线扰动强度。边缘UV密度降低时,同样的法线贴图灰度变化会被放大。此时可以根据UV梯度的长度缩放高度或法线扰动的强度。比如计算屏幕空间偏导数dpdx、dpdy,用长度与参考值的比值来压低边缘的凹凸强度,避免出现尖锐的假高光。
三、高度贴图校正:视差偏移与层面合成
高度贴图在校正投射边缘时,重点不是直接生成几何细节,而是修正采样位置。视差映射的基本原理是根据视线方向与表面切线的夹角,对原始UV进行偏移。投射边缘的UV本身已经变形,如果继续用固定倍率做视差偏移,边缘会出现明显错位。
改进的方式是使用带梯度修正的偏移量。在片段着色器中,先计算投影UV在屏幕空间的变化率,用它来归一化高度贴图采样。以下代码演示了如何结合高度贴图做边缘安全的视差偏移。
vec2 CorrectedParallaxUV(vec2 projUV, vec3 viewDir, vec3 worldNormal, sampler2D heightMap)
{
// 获取投影UV在屏幕上的变化率
vec2 uvDx = dFdx(projUV);
vec2 uvDy = dFdy(projUV);
float maxVariation = max(length(uvDx), length(uvDy));
// 高度采样并映射到偏移系数
float height = texture(heightMap, projUV).r;
float parallaxScale = 0.04 / max(maxVariation, 0.001);
// 视线在切线平面上的投影
vec3 tangentView = viewDir - worldNormal * dot(viewDir, worldNormal);
vec2 offset = tangentView.xy * height * parallaxScale;
return projUV + offset;
}
这里用dFdx和dFdy获得投影UV在屏幕空间的变化率,maxVariation表示当前像素覆盖的贴图范围。边缘区域这个值通常较大,因此parallaxScale会自动变小,减少过度偏移。这样即使贴图在边缘被拉伸,高度感也不会出现断裂。
高度贴图还可以用于层面合成。比如在岩石地面向墙面过渡的区域,可以为地面投影和墙面投影分别采样高度贴图,根据高度值决定两个投影面的混合顺序。高处的像素优先使用地面投射结果,低处优先使用墙面投射结果,这样能把接缝隐藏在高度变化中,而不是生硬地画一条混合线。
四、组合校正流程与参数调节
实际项目中,法线贴图和高度贴图校正通常要组合使用。可以先在顶点阶段计算多方向投射坐标,在片段阶段重建TBN、混合法线、做边缘安全的视差偏移,最后再计算光照。下面给出一个C#端配置参数的示例,方便在材质面板里调节边缘衰减和视差强度。
public class ProjectionCorrectionSettings
{
public float EdgeBlendPower = 3.0f;
public float NormalIntensityFalloff = 0.6f;
public float ParallaxScale = 0.04f;
public float MaxProjectionStretch = 4.0f;
public Vector4 PackShaderParams()
{
return new Vector4(
EdgeBlendPower,
NormalIntensityFalloff,
ParallaxScale,
MaxProjectionStretch
);
}
}
在着色器中,EdgeBlendPower控制投影权重曲线的锐利程度。值越大,投影面切换越果断,边缘过渡带越窄。NormalIntensityFalloff控制UV拉伸区域法线强度的衰减比例,通常设置在0.4到0.8之间。ParallaxScale是基础视差强度,需要与前面的梯度修正结合。MaxProjectionStretch用来限制最大UV变化率,当边缘拉伸超过这个倍数时,额外做一次混合,避免单方向拉伸过度。
调节参数时,可以先关闭法线贴图,只观察高度贴图视差校正后的边缘是否平滑。然后把法线贴图加回来,检查高光是否还有局部跳变。重点观察球体模型在视角旋转时,投射边缘的光照方向是否连续。若出现一条亮边或暗边,通常是切线混合权重过高,可以降低EdgeBlendPower,或者增大投影切线与原始切线的过渡范围。
需要特别留意的是,这套校正流程并不是要完全替代模型UV。对于已经有良好UV的模型,应该只在投射区域使用校正后的TBN,其余区域继续保持原始UV采样,避免浪费贴图细节。通过按投影权重做区域混合,可以在保持UV精度和修复投射边缘之间取得平衡。