在实时渲染中,几何体面数直接决定性能开销,但视觉细节并不完全由三角面数量决定。法线贴图通过纹理存储每个像素的法线方向,让着色器在光照计算时使用扰动后的法线,从而在平坦表面上模拟凹凸、划痕、织物纹理等高频细节。传统法线贴图制作依赖高模雕刻并烘焙到低模,流程重、迭代慢。AI生成法线图的出现,将纹理生成从手工绘制推进到数据驱动阶段。

先看一个直观现象:相同面数的低模,在未使用法线贴图时表面平坦、高光呆板;接入一张高质量法线贴图后,砖墙缝隙、皮革颗粒和金属拉丝都能被光照正确表现。这种增益并不增加三角形数量,却需要理解法线数据的编码方式与空间变换。
一、法线贴图如何“欺骗”光照:切线空间与数据编码
法线贴图本质上是一张二维纹理,但它的RGB通道并不表示颜色,而是存储法线向量的三个分量。大多数实时渲染使用切线空间法线贴图,该空间以模型表面切线、副切线和法线为基底。表面法线方向在切线空间中约为(0,0,1),经过映射到RGB后呈现偏蓝色调,这也是法线贴图整体看起来偏蓝的原因。着色器采样到颜色值后,需要经过normal = color * 2.0 - 1.0的变换,将范围从[0,1]映射到[-1,1],才能得到可用的法线方向。
为什么使用切线空间而不是模型空间?因为切线空间贴图与网格的绝对朝向无关,可以跨UV和不同模型复用,也方便实现UV动画。例如同一张砖墙法线贴图可以贴在墙壁、地面甚至旋转后的模型上,只要UV展开合理。切线空间到世界空间的变换依赖TBN矩阵,它由切线、副切线和法线三个向量组成。顶点着色器计算TBN后传递给片元着色器,片元将采样到的切线空间法线乘以TBN,即可得到世界空间法线。
下面的GLSL代码展示了法线贴图解码与空间变换的核心步骤:
uniform sampler2D normalMap;
varying vec2 vUv;
varying mat3 vTBN;
void main() {
vec3 tangentNormal = texture2D(normalMap, vUv).rgb;
tangentNormal = tangentNormal * 2.0 - 1.0;
vec3 worldNormal = normalize(vTBN * tangentNormal);
// 使用worldNormal继续光照计算
}
其中vTBN由顶点着色器传入,texture2D负责纹理采样。很多初学者容易忽略2.0 - 1.0这一步,直接把颜色值当作法线使用,结果光照方向完全错误。
二、AI生成法线贴图的主流技术路径
传统法线贴图制作流程依赖高模雕刻、低模拓扑展开和烘焙,需要熟练的建模技能。AI生成法线图的目标是降低门槛:输入一张普通的漫反射纹理或照片,直接输出切线空间法线贴图。早期方法基于卷积神经网络,如U-Net和pix2pix,在合成数据上训练从颜色图到法线图的映射。这类网络能够学习到颜色变化与表面凹凸之间的统计关联,例如岩石表面亮暗变化通常对应起伏,从而预测法线方向。
另一种路径是借助单目深度估计模型,如MiDaS或DPT,先预测输入图像的深度图,再对深度图求空间梯度得到法线。这种方法对真实照片有较好的泛化能力,因为深度估计模型在大规模真实场景数据上训练过。然而深度图的尺度不一致和分辨率限制会直接影响法线质量,尤其是高频细节容易丢失。实际使用时常需要对深度图进行双边滤波再计算梯度。
最近两年扩散模型与ControlNet的兴起带来了更强的纹理生成能力。法线图可以作为ControlNet的条件输入,让扩散模型生成与给定法线结构一致的材质贴图;也可以反向使用,输入纹理图或文本描述,输出对应的法线图。该方案在细节丰富度和可控性上表现突出,但推理成本较高,且生成的法线图需要额外验证切线空间一致性,避免出现空间翻转或接缝不连续。
可以从下表快速比较三种路径的适用场景:
| 方法 | 输入 | 优点 | 局限 |
|---|---|---|---|
| 卷积网络 | 漫反射图 | 速度较快,端到端 | 需要配对数据,细节有限 |
| 深度估计+梯度 | 单张照片 | 泛化好,适合真实照片 | 低频细节损失,尺度敏感 |
| 扩散模型 | 纹理图/文本 | 细节丰富,可控性强 | 计算量大,需后期验证 |
三、在3D引擎中应用AI生成的法线贴图
以Three.js为例,加载AI生成的法线贴图并应用到标准材质非常简单。核心是将纹理对象赋给MeshStandardMaterial的normalMap属性,同时可以通过normalScale调整凹凸强度。下面代码展示了基本用法:
import * as THREE from 'three';
const textureLoader = new THREE.TextureLoader();
const normalMap = textureLoader.load('normal_ai.png');
normalMap.wrapS = THREE.RepeatWrapping;
normalMap.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({
color: 0xffffff,
normalMap: normalMap,
normalScale: new THREE.Vector2(1.0, 1.0)
});
需要注意的是,法线贴图必须使用线性色彩空间。如果将法线贴图标记为sRGB,引擎在采样时会先做伽马校正,导致解码出来的法线方向偏差。在Three.js中可以对法线贴图设置colorSpace = THREE.NoColorSpace,或者在使用TextureLoader后手动设置。glTF 2.0规范也明确要求法线贴图应使用线性过滤和线性颜色空间,这一点在导入模型时容易被忽略。
如果使用自定义着色器,则需要自己完成TBN矩阵计算和法线解码。完整的顶点与片元着色器示例如下:
// 顶点着色器
attribute vec3 tangent;
varying vec2 vUv;
varying mat3 vTBN;
void main() {
vUv = uv;
vec3 bitangent = cross(normal, tangent);
vTBN = mat3(tangent, bitangent, normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 片元着色器
uniform sampler2D normalMap;
uniform float normalStrength;
varying vec2 vUv;
varying mat3 vTBN;
void main() {
vec3 n = texture2D(normalMap, vUv).rgb;
n = n * 2.0 - 1.0;
n.xy *= normalStrength;
n = normalize(n);
vec3 worldNormal = normalize(vTBN * n);
// 基于worldNormal计算漫反射和镜面反射
}
在顶点着色器中,bitangent通过法线与切线叉乘得到,但实际项目中副切线的方向可能与UV方向有关,若出现法线方向相反,可在导入时翻转切线或调整叉乘顺序。片元着色器里的normalStrength用来控制凹凸强度,设为1.0表示完全使用贴图法线,设为0.5表示减弱一半效果。
四、AI法线贴图的常见问题与优化实践
AI生成的法线贴图最常见的问题是UV接缝处出现光照断裂。由于生成模型对整张图像处理,不会主动考虑模型UV岛的边缘连续性,导致相邻UV岛之间法线过渡不自然。解决思路有两种:一是在生成前对UV展开图做边缘扩张,用相同背景填充扩边;二是在着色器中使用三平面映射替代UV采样,但三平面映射在低端设备上有额外性能开销。对于大多数静态模型,推荐在烘培阶段处理接缝,而不是在运行时修补。
另一个容易被忽视的问题是法线贴图的强度与噪声。AI生成的法线图可能在平坦区域产生不必要的高频噪声,使表面看起来发毛。可以在图像处理软件中对法线图做轻微高斯模糊,或者在着色器中对的法线xy分量进行平滑。但要注意,模糊法线贴图会损失细节,通常只对强度较弱或噪声明显的区域局部处理。强度调节除了在引擎设置normalScale外,也可以在生成时限制法线偏离表面法线的角度。
在移动端或Web端,法线贴图的带宽和采样开销需要重点优化。推荐使用BC5压缩格式存储法线贴图,因为BC5只保留两个通道(例如RG),Z分量可以在着色器中重建。下面代码展示了从两个通道重建法线的常见方法:
vec3 normalFromBC5(vec2 rg) {
vec3 n;
n.xy = rg * 2.0 - 1.0;
n.z = sqrt(max(1.0 - dot(n.xy, n.xy), 0.0));
return normalize(n);
}
这种方案可以减少一半以上的纹理带宽,但重建的Z分量始终为非负,因此不适用于法线Z分量为负的情况。实际项目中若法线偏离程度不高,该近似完全可用。最后,任何AI生成的法线贴图在正式使用前,都建议在多个光照角度下预览验证,尤其是金属、玻璃等高反射材质,法线方向的误差会被高光明显放大。