如果一个比较平坦的物体表面要添加更多的凹凸细节,但是我们又不想通过建模实现,这时候法线贴图就派上用场了。法线贴图是通过与灯光的交互来让一个平坦表面造成凹凸效果假象的,在基于Babylon.js的Shader入门之四:让Shader支持基础光照这一节的内容中,我们已经让Shader能够支持简单的灯光,这里我们让Shader来支持法线贴图。
这里我们使用了这样一张法线贴图:
最终呈现的效果参考如下:
顶点着色器
attribute vec3 position;
attribute vec3 normal; // 顶点法线
attribute vec2 uv; // 纹理坐标
attribute vec3 tangent; // 切线uniform mat4 worldViewProjection;
uniform mat4 world; // 世界矩阵
uniform vec3 lightPosition; // 光源位置varying vec3 vLightDir; // 传递光照方向到片元着色器
varying vec2 vUV; // 传递纹理坐标到片元着色器
varying mat3 vTBN; // 传递TBN矩阵到片元着色器void main() {gl_Position = worldViewProjection * vec4(position, 1.0);// 计算世界矩阵的3x3部分mat3 worldMat3 = mat3(world);// 计算切线空间矩阵 (TBN)vec3 T = normalize(worldMat3 * tangent);vec3 N = normalize(worldMat3 * normal);vec3 B = cross(T, N);vTBN = mat3(T, B, N);// 计算光源方向(从顶点指向光源)vec3 worldPosition = (world * vec4(position, 1.0)).xyz;vLightDir = normalize(lightPosition - worldPosition);// 传递纹理坐标vUV = uv;
}
这里我们对新增的内容做一些解释:
1.使用顶点切线数据
在attribute类型的数据中,我们除了添加了uv数据之外,还添加了tangent顶点切线数据。
attribute vec2 uv; // 纹理坐标
attribute vec3 tangent; // 切线
这里需要注意的是,在Babylon.js中,一个mesh的顶点数据中不一定包含切线数据,如果一个mesh缺少了顶点切线数据,可能导致整个材质变黑等问题,这个我们在后面使用案例里面会说明解决办法。
2. 计算世界矩阵的 3x3 部分
mat3 worldMat3 = mat3(world);
world
是一个 4x4 的世界矩阵,用于将顶点从模型空间转换到世界空间。mat3(world)
提取了world
矩阵的左上角 3x3 部分。这个 3x3 矩阵只保留了旋转和缩放信息,去除了平移部分。
为什么需要 3x3 矩阵?
- 对于法线(
normal
)、切线(tangent
)等方向向量,平移是没有意义的,因为它们只表示方向,而不是位置。 - 使用 3x3 矩阵可以避免平移对方向向量的影响。
3. 计算切线空间矩阵 (TBN)
vec3 T = normalize(worldMat3 * tangent);
vec3 N = normalize(worldMat3 * normal);
vec3 B = cross(T, N);
vTBN = mat3(T, B, N);
3.1 计算切线 (T
) 和法线 (N
)
T
(切线):
tangent
是顶点的切线向量,表示纹理坐标的 U 方向。worldMat3 * tangent
将切线从模型空间转换到世界空间。normalize()
将向量归一化,确保其长度为 1。
N
(法线):
normal
是顶点的法线向量,表示表面的垂直方向。worldMat3 * normal
将法线从模型空间转换到世界空间。normalize()
将向量归一化。
3.2 计算副切线 (B
)
B
(副切线,也称为双切线或副法线):
B = cross(T, N)
使用叉积计算副切线。- 叉积的结果是一个垂直于
T
和N
的向量,表示纹理坐标的 V 方向。 - 副切线、切线和法线共同构成了切线空间的基础。
3.3 构建 TBN 矩阵
vTBN = mat3(T, B, N)
:
TBN
矩阵是一个 3x3 矩阵,由切线 (T
)、副切线 (B
) 和法线 (N
) 组成。- 这个矩阵用于将向量从切线空间转换到世界空间,或者从世界空间转换到切线空间。
3. TBN 矩阵的作用
TBN 矩阵的主要作用是将向量从世界空间转换到切线空间,或者从切线空间转换到世界空间。在法线贴图(Normal Mapping)中,TBN 矩阵尤其重要:
- 法线贴图中的法线向量 是定义在切线空间中的。切线空间参考如下:
- 为了在片元着色器中使用这些法线向量,需要将光照方向、视线方向等从世界空间转换到切线空间。
- TBN 矩阵就是用来完成这种空间转换的。
4. 代码的整体作用
worldMat3
:提取世界矩阵的 3x3 部分,用于方向向量的变换。T
、N
、B
:计算切线、法线和副切线,并构建 TBN 矩阵。vTBN
:将 TBN 矩阵传递给片元着色器,用于后续的光照计算。
片元着色器
precision mediump float;uniform vec3 diffuseColor; // 基础颜色
uniform sampler2D normalMap; // 法线贴图varying vec3 vLightDir; // 接收从顶点着色器传来的光照方向
varying vec2 vUV; // 接收从顶点着色器传来的纹理坐标
varying mat3 vTBN; // 接收从顶点着色器传来的TBN矩阵void main() {// 从法线贴图中获取法线vec3 normalMapValue = texture2D(normalMap, vUV).xyz * 2.0 - 1.0; // 将法线从[0,1]映射到[-1,1]vec3 normal = normalize(vTBN * normalMapValue); // 将法线从切线空间转换到世界空间// 计算光照强度(点积),确保不小于0float lightIntensity = max(dot(normal, normalize(vLightDir)), 0.0);// 将光照强度应用于基础颜色gl_FragColor = vec4(diffuseColor * lightIntensity, 1.0);
}
获取法向量
代码行vec3 normalMapValue = texture2D(normalMap, vUV).xyz * 2.0 - 1.0;用于通过贴图来获取当前片元对应的法向量。
texture2D(normalMap, vUV)
texture2D
:这是一个 GLSL 内置函数,用于从 2D 纹理(这里是法线贴图)中采样颜色值。normalMap
:是传入的法线贴图纹理。vUV
是从顶点着色器传递过来的纹理坐标,范围通常是[0, 1]
.xyz
提取纹理采样的 RGB 分量。
- 法线贴图的每个像素通常存储了一个法线向量,其分量(R, G, B)分别对应法线的 X, Y, Z 分量。
- 例如,
(R, G, B) = (0.5, 0.5, 1.0)
表示法线向量(0, 0, 1)
(即垂直于表面)。
* 2.0 - 1.0
* 2.0 - 1.0
:将法线向量从 [0, 1]
的范围映射到 [-1, 1]
。
例如:
- 如果
R = 0.5
,则R * 2.0 - 1.0 = 0.0
。 - 如果
G = 0.0
,则G * 2.0 - 1.0 = -1.0
。 - 如果
B = 1.0
,则B * 2.0 - 1.0 = 1.0
。
将法线向量从切线空间转换到世界空间
代码行 vec3 normal = normalize(vTBN * normalMapValue);用于将法线向量从切线空间转换到世界空间,并进行归一化。其中vTBN
是从顶点着色器传递过来的 TBN 矩阵(Tangent-Bitangent-Normal 矩阵)。
使用示例
// 创建 ShaderMaterial
const material = new ShaderMaterial("BaseLight", scene, "./src/assets/Shaders/NormalTexture/NormalTexture",{attributes: ["position", "normal", "uv", "tangent"], // 包括法线属性uniforms: ["world", "worldViewProjection", "lightPosition", "diffuseColor", "normalMap"]});
// 设置光源位置
const lightPosition = new Vector3(10, 10, 10); // 示例光源位置
material.setVector3("lightPosition", lightPosition);
material.setColor3("diffuseColor", new Color3(0.85, 0.9, 1)); // 设置基础颜色
const normalTex = new Texture("./src/assets/Textures/Metal06.jpg", scene);
material.setTexture("normalMap", normalTex);if (!box.isVerticesDataPresent(VertexBuffer.TangentKind)) {const tangentsArray = ComputeTangents(box);if(tangentsArray && tangentsArray.length > 2){box.setVerticesData(VertexBuffer.TangentKind, tangentsArray, false);}
}
box.material = material;function ComputeTangents(mesh:AbstractMesh){const positions = mesh.getVerticesData( VertexBuffer.PositionKind);const uvs = mesh.getVerticesData( VertexBuffer.UVKind);const indices = mesh.getIndices();if(positions && uvs && indices){let tangents = new Array(positions.length).fill(0);for (let i = 0; i < indices.length; i += 3) {let i0 = indices[i];let i1 = indices[i + 1];let i2 = indices[i + 2];let p0 = new Vector3(positions[i0 * 3], positions[i0 * 3 + 1], positions[i0 * 3 + 2]);let p1 = new Vector3(positions[i1 * 3], positions[i1 * 3 + 1], positions[i1 * 3 + 2]);let p2 = new Vector3(positions[i2 * 3], positions[i2 * 3 + 1], positions[i2 * 3 + 2]);let uv0 = new Vector2(uvs[i0 * 2], uvs[i0 * 2 + 1]);let uv1 = new Vector2(uvs[i1 * 2], uvs[i1 * 2 + 1]);let uv2 = new Vector2(uvs[i2 * 2], uvs[i2 * 2 + 1]);let deltaPos1 = p1.subtract(p0);let deltaPos2 = p2.subtract(p0);let deltaUV1 = uv1.subtract(uv0);let deltaUV2 = uv2.subtract(uv0);let r = 1.0 / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x);let tangent = deltaPos1.scale(deltaUV2.y).subtract(deltaPos2.scale(deltaUV1.y)).scale(r);tangents[i0 * 4] = tangent.x;tangents[i0 * 4 + 1] = tangent.y;tangents[i0 * 4 + 2] = tangent.z;tangents[i0 * 4 + 3] = 1;tangents[i1 * 4] = tangent.x;tangents[i1 * 4 + 1] = tangent.y;tangents[i1 * 4 + 2] = tangent.z;tangents[i1 * 4 + 3] = 1;tangents[i2 * 4] = tangent.x;tangents[i2 * 4 + 1] = tangent.y;tangents[i2 * 4 + 2] = tangent.z;tangents[i2 * 4 + 3] = 1;}return tangents;}return null;
}
这里需要强调的是,在Babylon.js中,一个mesh不一定包含顶点切线数据,比如你使用MeshBuilder.CreateBox来创建一个Box,在Babylon.js当前的版本下,该Box是不带有顶点切线数据的。如果你通过Babylon.js的导出插件从三维建模软件中导出模型,你只要将导出切线选项勾选上就可以带有顶点切线数据导出了。如果遇到创建或者加载得到的模型本身没有顶点切线数据的情况,我们需要通过当前Mesh现有的顶点数据计算生成,具体方法可以参考上面代码中的ComputeTangents方法。