教程 37 - 法线贴图

上一篇:方向光照 | 下一篇:网格 Part 1 | 返回目录


📚 快速导航


目录


📖 简介

在上一教程中,我们实现了基础的方向光照。但即使有了光照,3D 物体的表面仍然看起来很平滑,缺少细节。如果要表现出砖块的凹凸、木纹的纹理、岩石的粗糙,我们需要大量的几何细节------但这会严重影响性能。

法线贴图 (Normal Mapping) 提供了一个巧妙的解决方案:通过纹理存储表面法线信息,在不增加几何复杂度的情况下,让表面看起来有丰富的细节。
With Normal Mapping 使用法线贴图 Without Normal Mapping 无法线贴图 Simple Geometry
简单几何体 Normal Map
法线贴图 TBN Matrix
切线空间矩阵 Detailed Lighting
细节光照 Detailed Surface
细节表面 Simple Geometry
简单几何体 Flat Lighting
平坦光照 Smooth Surface
平滑表面

核心思想:

复制代码
传统方法 (高多边形):
  几何体: ▓▓▓▓▓▓▓ (100万顶点)
  性能:   ⚠️ 很慢
  效果:   ✅ 细节丰富

法线贴图 (低多边形 + 纹理):
  几何体: ▓▓ (1000顶点)
  纹理:   🖼️ 法线贴图
  性能:   ✅ 快速
  效果:   ✅ 细节丰富

🎯 学习目标

目标 描述
理解法线贴图原理 掌握法线贴图如何表现表面细节
掌握切线空间 理解切线空间及 TBN 矩阵
计算切线向量 实现切线和副切线的计算
着色器实现 在着色器中应用法线贴图
材质系统集成 将法线贴图集成到材质系统

💡 法线贴图基础

什么是法线贴图

法线贴图 是一种特殊的纹理,其中每个像素存储的不是颜色,而是法线向量

复制代码
普通纹理 (Diffuse Map):
┌──────────────────┐
│  每个像素存储:   │
│  RGB = 颜色      │
│  (0.8, 0.5, 0.3) │
│  ↓               │
│  🟤 棕色         │
└──────────────────┘

法线贴图 (Normal Map):
┌──────────────────┐
│  每个像素存储:   │
│  RGB = 法线向量  │
│  (0.5, 0.5, 1.0) │
│  ↓               │
│  ↗ 法线方向      │
└──────────────────┘

法线贴图的视觉特征:

复制代码
典型的法线贴图 (蓝紫色调):
┌─────────────────────┐
│  🟦🟦🟪🟦🟦🟦      │  ← 大部分是蓝色
│  🟦🟪🟪🟪🟦🟦      │
│  🟦🟦🟦🟦🟦🟦      │
│  🟦🟪🟦🟦🟪🟦      │
└─────────────────────┘

为什么是蓝色?
  RGB = (0.5, 0.5, 1.0)
        ↓    ↓    ↓
        X    Y    Z (切线空间)

  平坦表面的法线 = (0, 0, 1)
  映射到颜色 = (0.5, 0.5, 1.0)
               ↓
              🟦 蓝色

为什么需要法线贴图

问题:表面细节需要大量几何体

复制代码
砖墙示例:

方法 1: 纯几何 (高多边形)
┌─────────────────┐
│ ▀▀▀▄▄▄▀▀▀       │  每块砖的凹凸都用几何体建模
│ ▄▄▄▀▀▀▄▄▄       │  顶点数: ~50,000
│ ▀▀▀▄▄▄▀▀▀       │  性能: ⚠️ 很慢
└─────────────────┘

方法 2: 法线贴图
┌─────────────────┐
│ ████████████     │  平面几何 (4个顶点)
│ ████████████     │  + 法线贴图 (纹理)
│ ████████████     │  顶点数: 4
└─────────────────┘  性能: ✅ 快速
     ↓
  应用法线贴图
     ↓
┌─────────────────┐
│ ▀▀▀▄▄▄▀▀▀       │  看起来有凹凸!
│ ▄▄▄▀▀▀▄▄▄       │  但实际是平面
│ ▀▀▀▄▄▄▀▀▀       │
└─────────────────┘

性能对比:

方法 顶点数 内存占用 渲染性能 视觉效果
纯几何 50,000 ~2 MB ⚠️ 慢 ✅ 好
法线贴图 4 ~1 KB 几何 + ~512 KB 纹理 ✅ 快 ✅ 好

法线贴图的优势

复制代码
优势:
✓ 性能高效:几何体简单,GPU 友好
✓ 内存节省:纹理比几何体小
✓ 动态光照:法线贴图响应光照变化
✓ LOD 友好:可以根据距离降低纹理分辨率

局限性:
✗ 轮廓不变:边缘仍然是平的
✗ 遮挡不准:凹陷不会产生真实阴影
✗ 视差问题:从侧面看可能穿帮

对比可视化:
       正面看              侧面看
┌───────────────┐   ┌───────────────┐
│   ▀▀▄▄▀▀      │   │   │           │ ← 轮廓是平的
│   ▄▄▀▀▄▄      │   │   │           │
│   ▀▀▄▄▀▀      │   │   │           │
└───────────────┘   └───────────────┘
 ✅ 看起来有深度     ⚠️ 侧面穿帮

🔧 切线空间

坐标空间概述

在 3D 渲染中,有多个坐标空间:

复制代码
坐标空间层级:
┌─────────────────────────────────────┐
│ Local Space (模型空间)              │
│   模型本身的坐标                     │
└──────────────┬──────────────────────┘
               │ Model Matrix
               ▼
┌─────────────────────────────────────┐
│ World Space (世界空间)              │
│   场景中的全局坐标                   │
└──────────────┬──────────────────────┘
               │ View Matrix
               ▼
┌─────────────────────────────────────┐
│ View Space (视图空间)               │
│   相对于相机的坐标                   │
└──────────────┬──────────────────────┘
               │ Projection Matrix
               ▼
┌─────────────────────────────────────┐
│ Clip Space (裁剪空间)               │
│   归一化设备坐标 (NDC)               │
└─────────────────────────────────────┘

法线贴图使用的空间:切线空间 (Tangent Space)

复制代码
Tangent Space (切线空间):
┌──────────────────────────────┐
│  以表面为参考的局部坐标系    │
│                              │
│      N (Normal)              │
│      ↑                       │
│      │                       │
│      │                       │
│      └─────→ T (Tangent)     │
│     ╱                        │
│    ╱                         │
│   B (Bitangent)              │
│                              │
│  • T = 纹理 U 方向           │
│  • B = 纹理 V 方向           │
│  • N = 表面法线              │
└──────────────────────────────┘

切线空间定义

切线空间是表面的局部坐标系:

c 复制代码
// 切线空间的三个基向量
vec3 T;  // Tangent (切线) - 沿纹理 U 方向
vec3 B;  // Bitangent (副切线) - 沿纹理 V 方向
vec3 N;  // Normal (法线) - 垂直于表面

// 正交基:T, B, N 互相垂直
dot(T, B) = 0
dot(T, N) = 0
dot(B, N) = 0

为什么需要切线空间?

复制代码
问题:法线贴图中的法线是相对于表面的

假设法线贴图存储 "向上" 的法线:
  法线 = (0, 0, 1)  ← 切线空间中的 "Z 轴向上"

但在世界空间中,表面可能是倾斜的:
┌────────────────────────────────┐
│ 平面 A (水平)                  │
│  ════════                      │
│    ↑ 法线 = (0, 1, 0)          │
│                                │
│ 平面 B (倾斜 45°)              │
│    ╱╱╱╱╱╱                      │
│   ↗ 法线 = (0.7, 0.7, 0)       │
└────────────────────────────────┘

解决方案:
1. 法线贴图中存储**切线空间**法线
2. 运行时使用 TBN 矩阵转换到**世界空间**

TBN矩阵

TBN 矩阵用于将法线从切线空间转换到世界空间:

c 复制代码
// TBN 矩阵 (3x3)
mat3 TBN = mat3(
    T.x, B.x, N.x,
    T.y, B.y, N.y,
    T.z, B.z, N.z
);

// 切线空间法线 → 世界空间法线
vec3 tangent_normal = texture(normal_map, uv).rgb;
tangent_normal = tangent_normal * 2.0 - 1.0;  // [0,1] → [-1,1]
vec3 world_normal = TBN * tangent_normal;

可视化:

复制代码
切线空间中的法线:
      N (Z)
      ↑
      │
      │ 法线贴图存储的法线
      └─────→ T (X)
     ╱
    ╱
   B (Y)

经过 TBN 矩阵变换后:
世界空间中的法线:
            Y
            ↑
            │
            │ 转换后的法线
            └─────→ X
           ╱
          ╱
         Z

光照计算使用世界空间法线:
  lighting = dot(world_normal, light_direction)

📐 顶点数据扩展

添加切线向量

之前的 vertex_3d 只包含切线 (tangent),现在完整了:

c 复制代码
// engine/src/math/math_types.h

typedef struct vertex_3d {
    vec3 position;  // 位置 (12 bytes)
    vec2 texcoord;  // 纹理坐标 (8 bytes)
    vec3 normal;    // 法线 (12 bytes)
    vec3 tangent;   // 切线 (12 bytes)
    // 总计: 44 bytes
} vertex_3d;

为什么只存储切线,不存储副切线?

c 复制代码
// 副切线可以在着色器中计算:
vec3 bitangent = cross(normal, tangent);

// 优势:
// • 节省内存:44 bytes vs 56 bytes
// • 保证正交:叉积自动正交
// • 性能:着色器中计算很快

切线计算

计算三角形的切线和副切线:

c 复制代码
// 对于三角形 (v0, v1, v2)
void calculate_tangent(
    vertex_3d* v0,
    vertex_3d* v1,
    vertex_3d* v2
) {
    // 1. 计算边向量
    vec3 edge1 = vec3_sub(v1->position, v0->position);
    vec3 edge2 = vec3_sub(v2->position, v0->position);

    // 2. 计算纹理坐标差
    vec2 delta_uv1 = vec2_sub(v1->texcoord, v0->texcoord);
    vec2 delta_uv2 = vec2_sub(v2->texcoord, v0->texcoord);

    // 3. 计算切线
    f32 f = 1.0f / (delta_uv1.x * delta_uv2.y - delta_uv2.x * delta_uv1.y);

    vec3 tangent;
    tangent.x = f * (delta_uv2.y * edge1.x - delta_uv1.y * edge2.x);
    tangent.y = f * (delta_uv2.y * edge1.y - delta_uv1.y * edge2.y);
    tangent.z = f * (delta_uv2.y * edge1.z - delta_uv1.y * edge2.z);
    tangent = vec3_normalized(tangent);

    // 4. 将切线赋值给三个顶点
    v0->tangent = tangent;
    v1->tangent = tangent;
    v2->tangent = tangent;
}

数学原理:

复制代码
三角形顶点:
  v0 = (x0, y0, z0), uv0 = (u0, v0)
  v1 = (x1, y1, z1), uv1 = (u1, v1)
  v2 = (x2, y2, z2), uv2 = (u2, v2)

边向量:
  E1 = v1 - v0 = (Δx1, Δy1, Δz1)
  E2 = v2 - v0 = (Δx2, Δy2, Δz2)

UV 差:
  ΔUV1 = (Δu1, Δv1)
  ΔUV2 = (Δu2, Δv2)

切线公式:
  E1 = Δu1 * T + Δv1 * B
  E2 = Δu2 * T + Δv2 * B

解方程:
  T = (Δv2 * E1 - Δv1 * E2) / (Δu1 * Δv2 - Δu2 * Δv1)
  B = (Δu1 * E2 - Δu2 * E1) / (Δu1 * Δv2 - Δu2 * Δv1)

可视化:

复制代码
三角形与纹理映射:
┌─────────────────────────┐
│  3D 空间                │      纹理空间
│                         │    ┌────────────┐
│     v2                  │    │ (u2,v2)    │
│     ●                   │    │  ●         │
│    ╱ ╲                  │    │ ╱ ╲        │
│   ╱   ╲                 │    │╱   ╲       │
│  ╱     ╲                │    ●─────●      │
│ ●───────●               │  (u0,v0) (u1,v1)│
│ v0      v1              │                  │
└─────────────────────────┘    └────────────┘

切线 T:
  • 沿纹理 U 方向 (水平)
  • 在 3D 空间中对应边 v0 → v1 的方向

副切线 B:
  • 沿纹理 V 方向 (垂直)
  • 在 3D 空间中对应边 v0 → v2 的方向

顶点布局更新

Vulkan 顶点输入描述:

c 复制代码
// engine/src/renderer/vulkan/vulkan_material_shader.c

VkVertexInputAttributeDescription attributes[4];

// 位置 (location 0)
attributes[0].location = 0;
attributes[0].binding = 0;
attributes[0].format = VK_FORMAT_R32G32B32_SFLOAT;
attributes[0].offset = offsetof(vertex_3d, position);

// 纹理坐标 (location 1)
attributes[1].location = 1;
attributes[1].binding = 0;
attributes[1].format = VK_FORMAT_R32G32_SFLOAT;
attributes[1].offset = offsetof(vertex_3d, texcoord);

// 法线 (location 2)
attributes[2].location = 2;
attributes[2].binding = 0;
attributes[2].format = VK_FORMAT_R32G32B32_SFLOAT;
attributes[2].offset = offsetof(vertex_3d, normal);

// 切线 (location 3)
attributes[3].location = 3;
attributes[3].binding = 0;
attributes[3].format = VK_FORMAT_R32G32B32_SFLOAT;
attributes[3].offset = offsetof(vertex_3d, tangent);

内存布局:

复制代码
vertex_3d 内存布局 (44 bytes):
┌─────────────────────────────────────────────┐
│ Offset │ Field    │ Type  │ Size           │
├────────┼──────────┼───────┼────────────────┤
│ 0      │ position │ vec3  │ 12 bytes       │
├────────┼──────────┼───────┼────────────────┤
│ 12     │ texcoord │ vec2  │ 8 bytes        │
├────────┼──────────┼───────┼────────────────┤
│ 20     │ normal   │ vec3  │ 12 bytes       │
├────────┼──────────┼───────┼────────────────┤
│ 32     │ tangent  │ vec3  │ 12 bytes       │
└─────────────────────────────────────────────┘
Total: 44 bytes per vertex

🎨 着色器实现

顶点着色器修改

glsl 复制代码
// assets/shaders/Builtin.MaterialShader.vert.glsl
#version 450

// ========== 输入 ==========
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec2 in_texcoord;
layout(location = 2) in vec3 in_normal;
layout(location = 3) in vec3 in_tangent;  // ← 新增:切线

// ========== UBO ==========
layout(set = 0, binding = 0) uniform global_uniform_object {
    mat4 projection;
    mat4 view;
    vec4 ambient_colour;
    vec3 view_position;
} global_ubo;

// ========== Push Constants ==========
layout(push_constant) uniform push_constants {
    mat4 model;
} u_push_constants;

// ========== 输出到片段着色器 ==========
layout(location = 0) out struct dto {
    vec2 tex_coord;
    vec3 normal;
    vec3 tangent;     // ← 新增:切线
    vec3 frag_pos;    // 世界空间位置
} out_dto;

void main() {
    // 1. 变换到裁剪空间
    vec4 world_pos = u_push_constants.model * vec4(in_position, 1.0);
    gl_Position = global_ubo.projection * global_ubo.view * world_pos;

    // 2. 传递纹理坐标
    out_dto.tex_coord = in_texcoord;

    // 3. 变换法线和切线到世界空间
    mat3 normal_matrix = transpose(inverse(mat3(u_push_constants.model)));
    out_dto.normal = normalize(normal_matrix * in_normal);
    out_dto.tangent = normalize(normal_matrix * in_tangent);

    // 4. 世界空间位置
    out_dto.frag_pos = world_pos.xyz;
}

为什么变换法线和切线?

复制代码
模型变换对法线的影响:
┌──────────────────────────────────┐
│ 原始模型 (单位矩阵)              │
│  ═════                           │
│    ↑ 法线 (0, 1, 0)              │
└──────────────────────────────────┘

非均匀缩放后 (scale(2, 0.5, 1)):
┌──────────────────────────────────┐
│ ══════════                       │
│      ↗ 法线错误! (0, 1, 0)       │
│     ↑ 应该是 (0, 2, 0) 归一化   │
└──────────────────────────────────┘

使用法线矩阵:
  normal_matrix = transpose(inverse(model_matrix))
  world_normal = normalize(normal_matrix * local_normal)

┌──────────────────────────────────┐
│ ══════════                       │
│     ↑ 法线正确! (0, 1, 0)        │
└──────────────────────────────────┘

片段着色器修改

glsl 复制代码
// assets/shaders/Builtin.MaterialShader.frag.glsl
#version 450

// ========== 输入 ==========
layout(location = 0) in struct dto {
    vec2 tex_coord;
    vec3 normal;
    vec3 tangent;
    vec3 frag_pos;
} in_dto;

// ========== UBO ==========
layout(set = 0, binding = 0) uniform global_uniform_object {
    mat4 projection;
    mat4 view;
    vec4 ambient_colour;
    vec3 view_position;
} global_ubo;

// ========== 纹理 ==========
layout(set = 1, binding = 0) uniform sampler2D diffuse_sampler;
layout(set = 1, binding = 1) uniform sampler2D normal_sampler;  // ← 新增:法线贴图

// ========== 推送常量 (局部 UBO) ==========
layout(set = 1, binding = 2) uniform material_uniform_object {
    vec4 diffuse_colour;
    vec3 light_direction;
    vec4 light_colour;
} material_ubo;

// ========== 输出 ==========
layout(location = 0) out vec4 out_colour;

void main() {
    // 1. 采样漫反射纹理
    vec4 diffuse = texture(diffuse_sampler, in_dto.tex_coord);

    // 2. 采样法线贴图
    vec3 tangent_normal = texture(normal_sampler, in_dto.tex_coord).rgb;
    tangent_normal = tangent_normal * 2.0 - 1.0;  // [0,1] → [-1,1]

    // 3. 构建 TBN 矩阵
    vec3 N = normalize(in_dto.normal);
    vec3 T = normalize(in_dto.tangent);
    T = normalize(T - dot(T, N) * N);  // Gram-Schmidt 正交化
    vec3 B = cross(N, T);
    mat3 TBN = mat3(T, B, N);

    // 4. 将法线从切线空间转换到世界空间
    vec3 world_normal = normalize(TBN * tangent_normal);

    // 5. 计算光照 (使用转换后的法线)
    // 环境光
    vec3 ambient = global_ubo.ambient_colour.rgb * global_ubo.ambient_colour.a;

    // 漫反射
    vec3 light_dir = normalize(-material_ubo.light_direction);
    float diff = max(dot(world_normal, light_dir), 0.0);
    vec3 diffuse_light = material_ubo.light_colour.rgb * diff;

    // 6. 组合最终颜色
    vec3 final_colour = (ambient + diffuse_light) * diffuse.rgb * material_ubo.diffuse_colour.rgb;
    out_colour = vec4(final_colour, diffuse.a);
}

TBN矩阵构建

为什么需要 Gram-Schmidt 正交化?

c 复制代码
// 问题:插值后的切线可能不再垂直于法线

顶点着色器输出:
  v0.tangent ⊥ v0.normal  ✓ 正交
  v1.tangent ⊥ v1.normal  ✓ 正交

片段着色器插值后:
  interpolated_tangent ⊥ interpolated_normal?  ✗ 可能不正交!

解决方案:Gram-Schmidt 正交化
  T' = T - (T · N) * N
       ↑    ↑       ↑
       |    |       └─ 法线方向上的投影
       |    └─ 投影长度
       └─ 原切线

可视化:
      N
      ↑
      │
      │  ╱ T (插值后,不垂直)
      │ ╱
      │╱
      └─────→

正交化后:
      N
      ↑
      │
      │
      │
      └─────→ T' (垂直)

完整的 TBN 构建:

glsl 复制代码
// 1. 归一化输入
vec3 N = normalize(in_dto.normal);
vec3 T = normalize(in_dto.tangent);

// 2. Gram-Schmidt 正交化
T = normalize(T - dot(T, N) * N);

// 3. 计算副切线
vec3 B = cross(N, T);

// 4. 构建 TBN 矩阵
mat3 TBN = mat3(
    T.x, B.x, N.x,  // 第一行
    T.y, B.y, N.y,  // 第二行
    T.z, B.z, N.z   // 第三行
);

// 或者用列向量表示 (GLSL 默认)
mat3 TBN = mat3(T, B, N);

// 5. 变换法线
vec3 world_normal = TBN * tangent_normal;

🔗 材质系统集成

法线贴图加载

材质配置文件扩展:

kmt 复制代码
# assets/materials/test_material.kmt

# 材质版本
version=0.1

# 材质名称
name=test_material

# 材质类型
type=world

# 漫反射颜色
diffuse_colour=1.0 1.0 1.0 1.0

# 漫反射贴图
diffuse_map_name=bricks_diffuse

# 法线贴图 (新增)
normal_map_name=bricks_normal

材质结构更新:

c 复制代码
// engine/src/resources/resource_types.h

typedef struct material {
    u32 id;
    u32 generation;
    u32 internal_id;
    material_type type;
    char name[MATERIAL_NAME_MAX_LENGTH];

    vec4 diffuse_colour;
    texture_map diffuse_map;

    // ========== 新增:法线贴图 ==========
    texture_map normal_map;
} material;

typedef struct material_config {
    char name[MATERIAL_NAME_MAX_LENGTH];
    material_type type;
    b8 auto_release;

    vec4 diffuse_colour;
    char diffuse_map_name[TEXTURE_NAME_MAX_LENGTH];

    // ========== 新增:法线贴图名称 ==========
    char normal_map_name[TEXTURE_NAME_MAX_LENGTH];
} material_config;

材质配置扩展

材质加载器解析:

c 复制代码
// engine/src/resources/loaders/material_loader.c

static b8 material_loader_load(resource_loader* self, const char* name, resource* out_resource) {
    // ... 打开文件 ...

    // 初始化默认值
    material_config* resource_data = kallocate(sizeof(material_config), MEMORY_TAG_MATERIAL);
    resource_data->type = MATERIAL_TYPE_WORLD;
    resource_data->auto_release = false;
    string_ncopy(resource_data->name, name, MATERIAL_NAME_MAX_LENGTH);
    resource_data->diffuse_colour = vec4_one();
    kzero_memory(resource_data->diffuse_map_name, TEXTURE_NAME_MAX_LENGTH);
    kzero_memory(resource_data->normal_map_name, TEXTURE_NAME_MAX_LENGTH);  // ← 新增

    // 逐行解析
    while (filesystem_read_line(&f, 511, &p, &line_length)) {
        char* trimmed = string_trim(line_buf);

        // ... 解析其他字段 ...

        // ========== 新增:解析法线贴图 ==========
        if (strings_equali(key_trimmed, "normal_map_name")) {
            string_ncopy(resource_data->normal_map_name, value_trimmed, TEXTURE_NAME_MAX_LENGTH);
        }
    }

    filesystem_close(&f);

    out_resource->data = resource_data;
    return true;
}

材质系统加载法线贴图:

c 复制代码
// engine/src/systems/material_system.c

material* material_system_acquire(const char* name) {
    // ... 加载材质配置 ...

    material_config* config = (material_config*)resource.data;
    material* m = &state_ptr->registered_materials[id].material;

    // 加载漫反射贴图
    if (string_length(config->diffuse_map_name) > 0) {
        m->diffuse_map.texture = texture_system_acquire(config->diffuse_map_name, true);
        if (!m->diffuse_map.texture) {
            m->diffuse_map.texture = texture_system_get_default_texture();
        }
    }

    // ========== 新增:加载法线贴图 ==========
    if (string_length(config->normal_map_name) > 0) {
        m->normal_map.texture = texture_system_acquire(config->normal_map_name, true);
        if (!m->normal_map.texture) {
            // 默认法线贴图 (平坦表面)
            m->normal_map.texture = texture_system_get_default_normal_map();
        }
    } else {
        // 没有指定法线贴图,使用默认
        m->normal_map.texture = texture_system_get_default_normal_map();
    }

    // ...
    return m;
}

默认法线贴图:

c 复制代码
// engine/src/systems/texture_system.c

texture* texture_system_get_default_normal_map() {
    if (!state_ptr) {
        KFATAL("texture_system_get_default_normal_map called before system init");
        return 0;
    }

    // 默认法线贴图:平坦表面,指向 Z 轴
    // RGB = (0.5, 0.5, 1.0) → 法线 = (0, 0, 1)
    static texture* default_normal_map = 0;

    if (!default_normal_map) {
        // 创建 1x1 纹理
        u8 pixels[4] = {
            128,  // R = 0.5 → X = 0
            128,  // G = 0.5 → Y = 0
            255,  // B = 1.0 → Z = 1
            255   // A = 1.0
        };

        default_normal_map = texture_system_acquire_from_memory(
            "default_normal",
            1, 1,
            4,
            pixels,
            false
        );
    }

    return default_normal_map;
}

纹理采样

着色器中绑定法线贴图:

c 复制代码
// engine/src/renderer/vulkan/vulkan_material_shader.c

b8 vulkan_material_shader_apply_material(
    vulkan_context* context,
    vulkan_material_shader* shader,
    material* m
) {
    // ... 其他代码 ...

    // 绑定纹理
    const u32 diffuse_texture_index = 0;
    const u32 normal_texture_index = 1;  // ← 法线贴图绑定点

    // 漫反射纹理
    vulkan_material_shader_set_texture(
        context,
        shader,
        m->diffuse_map.texture,
        diffuse_texture_index
    );

    // ========== 法线贴图 ==========
    vulkan_material_shader_set_texture(
        context,
        shader,
        m->normal_map.texture,  // ← 绑定法线贴图
        normal_texture_index
    );

    return true;
}

👁️ 渲染效果对比

法线贴图前后的视觉差异:

复制代码
无法线贴图 (平坦光照):
┌─────────────────────┐
│  ████████████████   │  砖墙看起来很平
│  ████████████████   │  所有砖块亮度相同
│  ████████████████   │  缺少凹凸细节
│  ████████████████   │
└─────────────────────┘

有法线贴图 (细节光照):
┌─────────────────────┐
│  ▓▓▒▒░░▓▓▒▒░░▓▓    │  每块砖有明暗变化
│  ░░▓▓▒▒░░▓▓▒▒░░    │  凹陷处更暗
│  ▓▓▒▒░░▓▓▒▒░░▓▓    │  凸起处更亮
│  ░░▓▓▒▒░░▓▓▒▒░░    │  看起来有深度!
└─────────────────────┘

动态光照对比:

复制代码
光源移动时:

无法线贴图:
┌─────────────────────┐
│     ☀️              │  光源
│  ████████████████   │  整个表面均匀变亮
│  ████████████████   │  没有细节变化
└─────────────────────┘

有法线贴图:
┌─────────────────────┐
│     ☀️              │  光源
│  ▓▓▒▒░░▓▓▒▒░░▓▓    │  凹凸处随光源变化
│  ░░▓▓▒▒░░▓▓▒▒░░    │  明暗对比动态调整
│       ⬇️            │  光照移动
│  ░░▓▓▒▒░░▓▓▒▒░░    │  细节响应光照变化
└─────────────────────┘

性能对比:

方法 几何复杂度 纹理内存 FPS (1080p) 视觉质量
高模 50,000 三角形 2 MB 漫反射 ~30 FPS ⭐⭐⭐⭐⭐
法线贴图 12 三角形 2 MB 漫反射 + 2 MB 法线 ~120 FPS ⭐⭐⭐⭐⭐

❓ 常见问题

1. 法线贴图为什么是蓝紫色的?

原因:

法线贴图存储的是切线空间的法线向量,映射到 RGB 颜色:

c 复制代码
切线空间法线 = (X, Y, Z)
             ↓ 映射到 [0,1]
RGB 颜色 = ((X+1)/2, (Y+1)/2, (Z+1)/2)

平坦表面 (大部分区域):
  法线 = (0, 0, 1)  ← Z 轴向上
  颜色 = (0.5, 0.5, 1.0)
       = RGB(128, 128, 255)
       = 🟦 蓝色

凸起 (法线向右倾斜):
  法线 = (0.5, 0, 0.866)
  颜色 = (0.75, 0.5, 0.933)
       = RGB(191, 128, 238)
       = 🟪 淡紫色

凹陷 (法线向左倾斜):
  法线 = (-0.5, 0, 0.866)
  颜色 = (0.25, 0.5, 0.933)
       = RGB(64, 128, 238)
       = 🟦 深蓝色

验证:

c 复制代码
// 在 Photoshop/GIMP 中查看法线贴图的颜色拾取器:
// 平坦区域应该显示 RGB(128, 128, 255)

2. 为什么要 `* 2.0 - 1.0`?

原因:

纹理存储的是 [0, 1] 范围的颜色,但法线向量范围是 [-1, 1]:

glsl 复制代码
// 从纹理采样得到 [0, 1] 范围的颜色
vec3 sampled = texture(normal_sampler, uv).rgb;  // [0, 1]

// 转换到 [-1, 1] 范围的法线
vec3 normal = sampled * 2.0 - 1.0;  // [-1, 1]

// 示例:
sampled = (0.5, 0.5, 1.0)  ← 纹理颜色
normal = (0.5, 0.5, 1.0) * 2.0 - 1.0
       = (1.0, 1.0, 2.0) - 1.0
       = (0.0, 0.0, 1.0)  ← 法线向量 (Z 轴)

sampled = (0.0, 0.5, 1.0)  ← 深蓝色
normal = (0.0, 0.5, 1.0) * 2.0 - 1.0
       = (0.0, 1.0, 2.0) - 1.0
       = (-1.0, 0.0, 1.0)  ← 法线向左倾斜

3. 什么时候需要 Gram-Schmidt 正交化?

需要正交化的情况:

复制代码
问题:插值破坏正交性

顶点 v0:
  T0 ⊥ N0 ✓

顶点 v1:
  T1 ⊥ N1 ✓

片段 (插值):
  T_interp = lerp(T0, T1)
  N_interp = lerp(N0, N1)
  T_interp ⊥ N_interp? ✗ 不一定正交!

可视化:
  v0 (T0, N0)          v1 (T1, N1)
      ●────────────────●
       ╲              ╱
        ╲  T_interp ╱  ← 不垂直!
         ╲        ╱
          ● fragment

解决方案:

glsl 复制代码
// 片段着色器中:
vec3 N = normalize(in_dto.normal);
vec3 T = normalize(in_dto.tangent);

// Gram-Schmidt 正交化
T = normalize(T - dot(T, N) * N);

// 现在 T ⊥ N ✓
vec3 B = cross(N, T);

何时可以跳过?

  • 如果模型没有变换 (model = identity)
  • 如果只有均匀缩放 (scale(s, s, s))
  • 如果对性能要求极高且视觉差异可接受

4. 法线贴图不起作用怎么办?

排查步骤:

1. 检查纹理绑定

c 复制代码
// 确保法线贴图绑定到正确的位置
layout(set = 1, binding = 1) uniform sampler2D normal_sampler;

2. 检查采样值

glsl 复制代码
// 在片段着色器中输出法线贴图
vec3 sampled = texture(normal_sampler, in_dto.tex_coord).rgb;
out_colour = vec4(sampled, 1.0);  // 应该显示蓝紫色

3. 检查切线数据

c 复制代码
// 确保切线已经计算并上传到 GPU
for (u32 i = 0; i < triangle_count; ++i) {
    calculate_tangent(&vertices[i*3], &vertices[i*3+1], &vertices[i*3+2]);
}

4. 检查 TBN 矩阵

glsl 复制代码
// 输出 TBN 矩阵的各个向量,检查是否正交
out_colour = vec4(T * 0.5 + 0.5, 1.0);  // 切线应该沿表面
out_colour = vec4(B * 0.5 + 0.5, 1.0);  // 副切线应该沿表面
out_colour = vec4(N * 0.5 + 0.5, 1.0);  // 法线应该垂直表面

5. 检查法线贴图格式

c 复制代码
// 确保法线贴图没有 sRGB 转换
VkFormat format = VK_FORMAT_R8G8B8A8_UNORM;  // ✅ 正确
// 不要使用:
VkFormat format = VK_FORMAT_R8G8B8A8_SRGB;   // ❌ 错误!

5. 如何创建法线贴图?

方法 1: 从高模烘焙

使用 3D 软件 (Blender, Maya, Substance Painter):

复制代码
1. 创建高模 (High-poly model) - 细节丰富
2. 创建低模 (Low-poly model) - 用于游戏
3. 烘焙法线贴图:
   • Blender: Cycles Render → Bake → Normal
   • Substance Painter: Bake Maps → Normal
   • xNormal: 专门的烘焙工具

方法 2: 从高度图生成

使用图像处理工具:

复制代码
1. 准备高度图 (Height Map):
   • 黑色 = 低
   • 白色 = 高

2. 转换为法线贴图:
   • Photoshop: Filter → 3D → Generate Normal Map
   • GIMP: Filters → Generic → Normal Map
   • 在线工具: cpetry.github.io/NormalMap-Online

方法 3: 手绘 (进阶)

使用 Substance Designer/Painter:

复制代码
1. 创建程序化材质
2. 定义表面细节 (凹凸、划痕、裂纹)
3. 导出法线贴图

验证法线贴图:

c 复制代码
// 检查颜色值
// • 主要是蓝紫色 (RGB ~128, 128, 255)
// • 没有纯黑或纯白
// • 平滑过渡 (没有突变)

📝 练习

练习 1: 可视化切线空间

任务: 在场景中绘制 TBN 基向量,以便调试。

c 复制代码
// 在游戏中添加调试绘制
void debug_draw_tbn(vec3 position, vec3 tangent, vec3 bitangent, vec3 normal) {
    f32 length = 0.5f;

    // 红色 = 切线 (T)
    debug_draw_line(position, vec3_add(position, vec3_mul_scalar(tangent, length)),
                    (vec4){1.0f, 0.0f, 0.0f, 1.0f});

    // 绿色 = 副切线 (B)
    debug_draw_line(position, vec3_add(position, vec3_mul_scalar(bitangent, length)),
                    (vec4){0.0f, 1.0f, 0.0f, 1.0f});

    // 蓝色 = 法线 (N)
    debug_draw_line(position, vec3_add(position, vec3_mul_scalar(normal, length)),
                    (vec4){0.0f, 0.0f, 1.0f, 1.0f});
}

// 在渲染循环中调用
void game_render() {
    for (u32 i = 0; i < vertex_count; ++i) {
        vertex_3d* v = &vertices[i];
        vec3 bitangent = vec3_cross(v->normal, v->tangent);
        debug_draw_tbn(v->position, v->tangent, bitangent, v->normal);
    }
}

预期结果:

复制代码
每个顶点显示三条线:
  🔴 红线 (切线) - 沿纹理 U 方向
  🟢 绿线 (副切线) - 沿纹理 V 方向
  🔵 蓝线 (法线) - 垂直表面

练习 2: 法线贴图强度控制

任务: 添加法线贴图强度参数,允许调节凹凸效果。

c 复制代码
// 材质配置
typedef struct material_config {
    // ... 其他字段 ...
    char normal_map_name[TEXTURE_NAME_MAX_LENGTH];
    f32 normal_strength;  // ← 新增:法线强度 (0.0 ~ 2.0)
} material_config;

// 配置文件
// test_material.kmt
normal_map_name=bricks_normal
normal_strength=1.5  // 增强凹凸效果

着色器实现:

glsl 复制代码
// 片段着色器
layout(set = 1, binding = 2) uniform material_ubo {
    vec4 diffuse_colour;
    vec3 light_direction;
    vec4 light_colour;
    float normal_strength;  // ← 新增
} material;

void main() {
    // 采样法线贴图
    vec3 tangent_normal = texture(normal_sampler, in_dto.tex_coord).rgb;
    tangent_normal = tangent_normal * 2.0 - 1.0;

    // ========== 应用强度 ==========
    tangent_normal.xy *= material.normal_strength;  // 缩放 X 和 Y
    tangent_normal = normalize(tangent_normal);     // 重新归一化

    // 继续 TBN 变换...
}

效果:

复制代码
normal_strength = 0.0:
  ████████  完全平坦 (忽略法线贴图)

normal_strength = 0.5:
  ▓▓▒▒░░▓▓  轻微凹凸

normal_strength = 1.0:
  ▓▓▒▒░░▓▓  正常凹凸

normal_strength = 2.0:
  ▓▓▒▒░░▓▓  夸张凹凸

练习 3: 视差映射 (Parallax Mapping)

任务: 实现简单的视差映射,增强深度感。

视差映射在法线贴图基础上,根据视角偏移纹理坐标,模拟深度:

glsl 复制代码
// 片段着色器
layout(set = 1, binding = 2) uniform sampler2D height_sampler;  // 高度图

vec2 parallax_mapping(vec2 uv, vec3 view_dir) {
    // 1. 采样高度图
    float height = texture(height_sampler, uv).r;

    // 2. 计算偏移
    float parallax_scale = 0.1;  // 视差强度
    vec2 offset = view_dir.xy * (height * parallax_scale);

    // 3. 偏移纹理坐标
    return uv - offset;
}

void main() {
    // 计算视角方向 (切线空间)
    vec3 view_dir = normalize(TBN * (global_ubo.view_position - in_dto.frag_pos));

    // 应用视差映射
    vec2 uv = parallax_mapping(in_dto.tex_coord, view_dir);

    // 使用偏移后的 UV 采样纹理
    vec4 diffuse = texture(diffuse_sampler, uv);
    vec3 tangent_normal = texture(normal_sampler, uv).rgb;

    // ... 继续光照计算 ...
}

效果对比:

复制代码
无视差:
  从侧面看,表面看起来平坦

有视差:
  从侧面看,凹陷处的纹理会偏移,产生深度错觉

恭喜!你已经掌握了法线贴图! 🎉

Tutorial written by 上手实验室

相关推荐
feiduoge2 小时前
教程 42 - 可写纹理
windows·游戏引擎·图形渲染
charlie1145141912 小时前
深入解构:MSVC 调试机制与 Visual Studio 调试器原理
c++·ide·windows·学习·visual studio·调试·现代c++
武藤一雄3 小时前
[奇淫巧技] WPF篇 (长期更新)
windows·microsoft·c#·.net·wpf
qq_428639613 小时前
虚幻基础:mod制作流程
游戏引擎·虚幻
BoBoZz193 小时前
IterativeClosestPoints icp配准矩阵
python·vtk·图形渲染·图形处理
月明长歌4 小时前
【码道初阶】【Leetcode94&144&145】二叉树的前中后序遍历(非递归版):显式调用栈的优雅实现
java·数据结构·windows·算法·leetcode·二叉树
李斯维4 小时前
安装 WSL 最好的方式
linux·windows
ITHAOGE154 小时前
下载 | Win11 官方精简版,系统占用空间极少!(12月更新、Win 11 IoT物联网 LTSC版、适合老电脑安装使用)
windows·科技·物联网·microsoft·微软·电脑
石像鬼₧魂石6 小时前
内网渗透靶场 攻击 & 排错命令分类速查表
linux·windows·学习·ubuntu