教程 36 - 方向光照

上一篇:在UI渲染通道中绘制 | 下一篇:法线贴图 | 返回目录


📚 快速导航


目录


📖 简介

在之前的教程中,我们实现了纹理映射,但渲染的3D物体看起来很"平"(flat),缺乏深度感和立体感。这是因为我们还没有实现光照 (Lighting)。

本教程将实现最基础的光照模型:方向光照 (Directional Lighting),包括:

  • 环境光 (Ambient Light):场景的基础照明
  • 漫反射 (Diffuse):根据光照方向和表面朝向计算的光照
  • Lambert 漫反射模型:一个简单但有效的光照公式

通过添加光照,3D物体将展现出真实的立体感和深度,为更高级的渲染技术(如镜面高光、法线贴图、阴影)奠定基础。
Output 输出 Calculation 计算 Inputs 输入 Lighting Pipeline 光照管线 Final Color
最终颜色 Normal Transform
法线变换 Dot Product
点积 Ambient
环境光 Diffuse
漫反射 Normal
法线 Light Direction
光源方向 Ambient Color
环境光颜色 Light Color
光源颜色 Vertex Shader
顶点着色器 Fragment Shader
片段着色器


🎯 学习目标

目标 描述
理解光照基础 掌握环境光、漫反射、方向光的概念
实现法线变换 正确变换法线向量以适应模型变换
Lambert漫反射 实现经典的Lambert光照模型
立方体几何体 生成带法线的立方体
光照着色器 更新着色器以计算光照

💡 光照基础

光照模型概述

真实世界的光照非常复杂,但我们可以用简化的模型近似它:

复制代码
完整的 Phong 光照模型:
┌─────────────────────────────────┐
│ Final Color = Ambient +         │
│               Diffuse +          │
│               Specular           │
└─────────────────────────────────┘

本教程实现 (简化版):
┌─────────────────────────────────┐
│ Final Color = Ambient +         │
│               Diffuse            │
└─────────────────────────────────┘

未来教程将添加:
- Specular (镜面高光)
- Normal Mapping (法线贴图)
- Shadows (阴影)
- Global Illumination (全局光照)

环境光

环境光 (Ambient Light) 是场景中的基础照明,模拟间接光照:

c 复制代码
// 环境光是常数,所有点接收相同的环境光
vec4 ambient_color = vec4(0.2, 0.2, 0.2, 1.0);  // 20% 亮度

vec4 ambient = ambient_color * diffuse_color * texture_sample;

环境光特性:

复制代码
没有环境光:
┌────────────┐
│    ████    │  背光面完全黑暗
│   ██  ██   │  (不真实)
│   ██  ██   │
│    ████    │
└────────────┘

有环境光:
┌────────────┐
│    ████    │  背光面仍可见
│   ██▓▓██   │  (更真实)
│   ██▓▓██   │
│    ████    │
└────────────┘

环境光强度:
  0.0 → 完全黑暗
  0.2 → 微弱环境光 (推荐)
  0.5 → 中等环境光
  1.0 → 完全照亮 (无阴影)

漫反射光

漫反射 (Diffuse Light) 是光线照射到粗糙表面时的散射:

复制代码
光线照射粗糙表面:
      光源
       │
       │ 入射光
       ▼
    ╱──────╲
   ╱ ▲ ▲ ▲ ▲ ╲  表面粗糙
  ╱  │ │ │ │  ╲  光线向各方向散射
 ╱───┴─┴─┴─┴───╲

特性:
1. 与视角无关 (从任何角度看都一样亮)
2. 依赖表面朝向 (法线方向)
3. 依赖光源方向

Lambert 漫反射公式:

复制代码
diffuse_factor = max(dot(normal, -light_direction), 0.0)

- dot(): 点积,计算两个向量的夹角
- normal: 表面法线 (单位向量)
- light_direction: 从表面指向光源的方向 (单位向量)
- max(..., 0.0): 夹角 > 90° 时,表面背光,漫反射为 0

示例:
  法线与光线同向 (0°):
    dot = 1.0 → 最亮

  法线与光线垂直 (90°):
    dot = 0.0 → 不受光照

  法线背向光线 (180°):
    dot = -1.0 → max(dot, 0.0) = 0.0 → 完全黑暗

方向光

方向光 (Directional Light) 模拟无限远处的光源 (如太阳):

c 复制代码
// 方向光结构
struct directional_light {
    vec3 direction;  // 光线方向 (从光源出发)
    vec4 colour;     // 光源颜色和强度
};

// 示例:从右上方照射的白光
directional_light sun = {
    vec3(-0.57735, -0.57735, -0.57735),  // 归一化的方向向量
    vec4(0.8, 0.8, 0.8, 1.0)             // 80% 强度的白光
};

方向光特性:

复制代码
方向光 vs 点光源:
┌─────────────────────────────────┐
│ 方向光 (Directional Light)      │
│                                 │
│   光源 (无限远)                  │
│     │││││                       │
│     │││││  平行光线              │
│     │││││                       │
│     ▼▼▼▼▼                       │
│   ████████  场景                │
│                                 │
│ 优点:性能好,适合室外场景         │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ 点光源 (Point Light)            │
│                                 │
│       ● 光源                     │
│      ╱│╲                        │
│     ╱ │ ╲  发散光线              │
│    ╱  │  ╲                      │
│   ▼   ▼   ▼                     │
│   ████████  场景                │
│                                 │
│ 优点:更真实,适合室内场景         │
└─────────────────────────────────┘

📐 法线向量

什么是法线

法线 (Normal) 是垂直于表面的单位向量:

复制代码
法线可视化:
      │ ← 法线 (垂直于表面)
      │
      │
  ────┴────  表面

立方体的法线:
        ▲ 顶面法线 (0, 1, 0)
        │
    ┌───┴───┐
◄───┤       ├───► 右面法线 (1, 0, 0)
    │       │
    └───┬───┘
        │
        ▼ 底面法线 (0, -1, 0)

每个顶点都有自己的法线向量
平面表面:所有顶点共享相同的法线
曲面表面:每个顶点的法线不同 (平滑过渡)

法线的重要性

法线决定了表面如何与光照交互:

复制代码
相同的几何体,不同的法线:
┌────────────────────────────────┐
│ Flat Shading (平坦着色)         │
│ - 每个三角形使用一个法线         │
│                                │
│    ▲ ▲                         │
│   ╱│ │╲                        │
│  ╱ │ │ ╲                       │
│ ───┴─┴───                      │
│                                │
│ 效果:棱角分明,适合立方体         │
└────────────────────────────────┘

┌────────────────────────────────┐
│ Smooth Shading (平滑着色)       │
│ - 每个顶点使用插值的法线         │
│                                │
│     ▲                          │
│    ╱ ╲                         │
│   ╱   ╲                        │
│  ───────                       │
│                                │
│ 效果:平滑曲面,适合球体、圆柱     │
└────────────────────────────────┘

法线变换

当模型变换 (缩放、旋转、平移) 时,法线也需要变换:

c 复制代码
// ❌ 错误:直接使用模型矩阵
vec3 world_normal = mat3(model) * in_normal;

// ✓ 正确:使用法线矩阵 (normal matrix)
mat3 normal_matrix = transpose(inverse(mat3(model)));
vec3 world_normal = normal_matrix * in_normal;

// 但在本教程中,我们只使用旋转和平移 (无非均匀缩放)
// 所以可以简化为:
vec3 world_normal = mat3(model) * in_normal;

为什么法线需要特殊处理?

复制代码
非均匀缩放的问题:
原始:
  │ 法线 ↑
  │
──┴──  正方形

非均匀缩放 (X 轴 2 倍):
  │ 法线 ↑ (错误!)
  │      应该倾斜 ↗
──┴────  矩形

使用法线矩阵后:
      ↗ 法线 (正确!)
     ╱
──────  矩形

🎨 顶点着色器更新

输入布局修改

添加法线作为顶点输入:

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

// ========== 输入 (更新) ==========
layout(location = 0) in vec3 in_position;  // 位置
layout(location = 1) in vec3 in_normal;    // ← 新增:法线
layout(location = 2) in vec2 in_texcoord;  // 纹理坐标

// ========== 全局 UBO (更新) ==========
layout(set = 0, binding = 0) uniform global_uniform_object {
    mat4 projection;
    mat4 view;
    vec4 ambient_colour;  // ← 新增:环境光颜色
} global_ubo;

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

// ========== 输出 ==========
layout(location = 0) out int out_mode;

// Data Transfer Object
layout(location = 1) out struct dto {
    vec4 ambient;    // ← 新增:环境光
    vec2 tex_coord;  // 纹理坐标
    vec3 normal;     // ← 新增:法线
} out_dto;

void main() {
    // 传递纹理坐标
    out_dto.tex_coord = in_texcoord;

    // 变换法线到世界空间
    // 注意:这里假设模型矩阵只包含旋转和平移 (无非均匀缩放)
    out_dto.normal = mat3(u_push_constants.model) * in_normal;

    // 传递环境光颜色
    out_dto.ambient = global_ubo.ambient_colour;

    // 计算顶点位置
    gl_Position = global_ubo.projection * global_ubo.view * u_push_constants.model * vec4(in_position, 1.0);
}

输入布局变化:

旧版本 新版本 变化
location = 0: position location = 0: position 不变
location = 1: texcoord location = 1: normal 新增
location = 2: texcoord 位置变化

法线变换

法线从模型空间变换到世界空间:

glsl 复制代码
// 提取模型矩阵的旋转部分 (3x3)
mat3 rotation_part = mat3(u_push_constants.model);

// 变换法线
vec3 world_normal = rotation_part * in_normal;

// 注意:如果模型包含非均匀缩放,需要使用法线矩阵
// mat3 normal_matrix = transpose(inverse(mat3(model)));
// vec3 world_normal = normal_matrix * in_normal;

矩阵分解:

复制代码
模型矩阵 (4x4):
┌───────────────┬────┐
│  旋转 + 缩放   │ 0  │
│   (3x3)       │ 0  │
│               │ 0  │
├───────────────┼────┤
│   平移 (3)    │ 1  │
└───────────────┴────┘

法线只需要旋转部分:
┌───────────────┐
│  mat3(model)  │
│   (3x3)       │
│               │
└───────────────┘

数据传递

通过 Data Transfer Object (DTO) 将数据传递到片段着色器:

glsl 复制代码
// 顶点着色器输出 (每个顶点)
out_dto.ambient = global_ubo.ambient_colour;  // vec4
out_dto.tex_coord = in_texcoord;              // vec2
out_dto.normal = world_normal;                // vec3

// 光栅化阶段自动插值

// 片段着色器输入 (每个片段/像素)
in_dto.ambient    // 插值后的环境光
in_dto.tex_coord  // 插值后的纹理坐标
in_dto.normal     // 插值后的法线 (需要重新归一化!)

🎨 片段着色器更新

方向光结构

定义方向光结构:

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

layout(location = 0) out vec4 out_colour;

// ========== 材质 UBO ==========
layout(set = 1, binding = 0) uniform local_uniform_object {
    vec4 diffuse_colour;
} object_ubo;

// ========== 方向光结构 ==========
struct directional_light {
    vec3 direction;  // 光线方向 (从光源出发,指向场景)
    vec4 colour;     // 光源颜色和强度 (RGB + 未使用的 A)
};

// TODO: 未来将从 CPU 传递,目前硬编码
directional_light dir_light = {
    vec3(-0.57735, -0.57735, -0.57735),  // 归一化向量,指向右下后方
    vec4(0.8, 0.8, 0.8, 1.0)             // 80% 强度的白光
};

// ========== 纹理采样器 ==========
layout(set = 1, binding = 1) uniform sampler2D diffuse_sampler;

// ========== 输入 (从顶点着色器) ==========
layout(location = 1) in struct dto {
    vec4 ambient;    // 环境光颜色
    vec2 tex_coord;  // 纹理坐标
    vec3 normal;     // 世界空间法线
} in_dto;

// ========== 函数声明 ==========
vec4 calculate_directional_light(directional_light light, vec3 normal);

void main() {
    // 计算最终颜色
    out_colour = calculate_directional_light(dir_light, in_dto.normal);
}

光线方向解释:

复制代码
光线方向 (-0.57735, -0.57735, -0.57735):
        Y (上)
        │
        │
        └────── X (右)
       ╱
      ╱
     Z (前)

方向向量:
  从原点指向 (-1, -1, -1) 并归一化
  = normalize((-1, -1, -1))
  ≈ (-0.57735, -0.57735, -0.57735)

可视化:
       光源 (无限远)
      ╱
     ╱ 光线方向
    ╱
   ▼
  场景

Lambert漫反射

实现 Lambert 漫反射计算:

glsl 复制代码
vec4 calculate_directional_light(directional_light light, vec3 normal) {
    // 1. 采样漫反射纹理
    vec4 diff_samp = texture(diffuse_sampler, in_dto.tex_coord);

    // 2. 计算漫反射因子
    // dot(normal, -light.direction):
    //   - normal: 表面法线 (从表面指向外)
    //   - -light.direction: 从表面指向光源的方向
    //   - dot 结果: cos(θ),其中 θ 是法线与光线的夹角
    float diffuse_factor = max(dot(normal, -light.direction), 0.0);

    // 3. 计算环境光分量
    vec4 ambient = vec4(vec3(in_dto.ambient * object_ubo.diffuse_colour), diff_samp.a);
    ambient *= diff_samp;  // 乘以纹理颜色

    // 4. 计算漫反射分量
    vec4 diffuse = vec4(vec3(light.colour * diffuse_factor), diff_samp.a);
    diffuse *= diff_samp;  // 乘以纹理颜色

    // 5. 组合环境光和漫反射
    return (ambient + diffuse);
}

光照计算

完整的光照计算流程:

复制代码
光照计算管线:
┌─────────────────────────────────┐
│ 1. 采样纹理                      │
│    diff_samp = texture(...)     │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│ 2. 计算漫反射因子                │
│    diffuse_factor = max(        │
│        dot(normal, -light_dir), │
│        0.0                      │
│    )                            │
└──────────────┬──────────────────┘
               │
         ┌─────┴─────┐
         │           │
         ▼           ▼
┌───────────┐ ┌─────────────┐
│ 3. 环境光  │ │ 4. 漫反射   │
│ ambient = │ │ diffuse =   │
│ ambient_  │ │ light_color │
│ color *   │ │ * diffuse_  │
│ texture   │ │ factor *    │
│           │ │ texture     │
└─────┬─────┘ └──────┬──────┘
      │              │
      └──────┬───────┘
             ▼
┌─────────────────────────────────┐
│ 5. 组合                         │
│    final = ambient + diffuse    │
└─────────────────────────────────┘

数学公式:

复制代码
Lambert 漫反射公式:
  I_diffuse = I_light × max(N · L, 0) × K_d

其中:
  I_diffuse = 漫反射光强度
  I_light = 光源强度
  N = 表面法线 (单位向量)
  L = 从表面指向光源的方向 (单位向量)
  N · L = 点积 (dot product)
  K_d = 材质的漫反射系数 (纹理颜色 × 材质颜色)

最终颜色:
  Final = Ambient + Diffuse
        = (I_ambient × K_d) + (I_light × max(N · L, 0) × K_d)

📦 立方体几何体生成

立方体顶点布局

立方体有 6 个面,每个面 4 个顶点,共 24 个顶点:

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

geometry_config geometry_system_generate_cube_config(
    f32 width, f32 height, f32 depth,
    f32 tile_x, f32 tile_y,
    const char* name, const char* material_name
) {
    geometry_config config;
    config.vertex_size = sizeof(vertex_3d);
    config.vertex_count = 4 * 6;  // 4 verts per side, 6 sides
    config.vertices = kallocate(sizeof(vertex_3d) * config.vertex_count, MEMORY_TAG_ARRAY);
    config.index_size = sizeof(u32);
    config.index_count = 6 * 6;  // 6 indices per side, 6 sides
    config.indices = kallocate(sizeof(u32) * config.index_count, MEMORY_TAG_ARRAY);

    f32 half_width = width * 0.5f;
    f32 half_height = height * 0.5f;
    f32 half_depth = depth * 0.5f;

    vertex_3d verts[24];

    // 6 个面,每面 4 个顶点
    // ...
}

立方体布局:

复制代码
立方体的 6 个面:
       顶面 (5)
      ┌───────┐
     ╱│      ╱│
    ╱ │     ╱ │
   ╱  │    ╱  │ 右面 (3)
  ┌───────┐   │
  │   │   │   │
左│   └───│───┘
面│  ╱    │  ╱
(2│ ╱  前 │ ╱
  │╱  面  │╱
  └───────┘ (0)
    底面 (4)

  后面 (1)

索引顺序:
- 面 0 (前面): 顶点 0-3
- 面 1 (后面): 顶点 4-7
- 面 2 (左面): 顶点 8-11
- 面 3 (右面): 顶点 12-15
- 面 4 (底面): 顶点 16-19
- 面 5 (顶面): 顶点 20-23

每面法线计算

每个面的所有顶点共享相同的法线:

c 复制代码
// 前面 (面向 +Z 方向)
verts[(0 * 4) + 0].position = (vec3){min_x, min_y, max_z};
verts[(0 * 4) + 1].position = (vec3){max_x, max_y, max_z};
verts[(0 * 4) + 2].position = (vec3){min_x, max_y, max_z};
verts[(0 * 4) + 3].position = (vec3){max_x, min_y, max_z};
// 纹理坐标
verts[(0 * 4) + 0].texcoord = (vec2){min_uvx, min_uvy};
verts[(0 * 4) + 1].texcoord = (vec2){max_uvx, max_uvy};
verts[(0 * 4) + 2].texcoord = (vec2){min_uvx, max_uvy};
verts[(0 * 4) + 3].texcoord = (vec2){max_uvx, min_uvy};
// 法线 (前面指向 +Z)
verts[(0 * 4) + 0].normal = (vec3){0.0f, 0.0f, 1.0f};
verts[(0 * 4) + 1].normal = (vec3){0.0f, 0.0f, 1.0f};
verts[(0 * 4) + 2].normal = (vec3){0.0f, 0.0f, 1.0f};
verts[(0 * 4) + 3].normal = (vec3){0.0f, 0.0f, 1.0f};

// 后面 (面向 -Z 方向)
verts[(1 * 4) + 0].position = (vec3){max_x, min_y, min_z};
verts[(1 * 4) + 1].position = (vec3){min_x, max_y, min_z};
verts[(1 * 4) + 2].position = (vec3){max_x, max_y, min_z};
verts[(1 * 4) + 3].position = (vec3){min_x, min_y, min_z};
// 法线 (后面指向 -Z)
verts[(1 * 4) + 0].normal = (vec3){0.0f, 0.0f, -1.0f};
verts[(1 * 4) + 1].normal = (vec3){0.0f, 0.0f, -1.0f};
verts[(1 * 4) + 2].normal = (vec3){0.0f, 0.0f, -1.0f};
verts[(1 * 4) + 3].normal = (vec3){0.0f, 0.0f, -1.0f};

// 左面 (面向 -X 方向)
// normal = (-1.0f, 0.0f, 0.0f)

// 右面 (面向 +X 方向)
// normal = (1.0f, 0.0f, 0.0f)

// 底面 (面向 -Y 方向)
// normal = (0.0f, -1.0f, 0.0f)

// 顶面 (面向 +Y 方向)
// normal = (0.0f, 1.0f, 0.0f)

法线方向总结:

方向 法线
前面 +Z (0, 0, 1)
后面 -Z (0, 0, -1)
左面 -X (-1, 0, 0)
右面 +X (1, 0, 0)
底面 -Y (0, -1, 0)
顶面 +Y (0, 1, 0)

索引生成

为每个面生成索引:

c 复制代码
// 为 6 个面生成索引
for (u32 i = 0; i < 6; ++i) {
    u32 v_offset = i * 4;   // 顶点偏移
    u32 i_offset = i * 6;   // 索引偏移

    // 两个三角形组成一个四边形
    // Triangle 1: 0 → 1 → 2
    ((u32*)config.indices)[i_offset + 0] = v_offset + 0;
    ((u32*)config.indices)[i_offset + 1] = v_offset + 1;
    ((u32*)config.indices)[i_offset + 2] = v_offset + 2;

    // Triangle 2: 0 → 3 → 1
    ((u32*)config.indices)[i_offset + 3] = v_offset + 0;
    ((u32*)config.indices)[i_offset + 4] = v_offset + 3;
    ((u32*)config.indices)[i_offset + 5] = v_offset + 1;
}

四边形三角化:

复制代码
四边形顶点:
  2 ────── 1
  │       │
  │       │
  0 ────── 3

三角形 1 (逆时针):
  2
  │╲
  │ ╲
  0 ─ 1

三角形 2 (逆时针):
  0 ─ 3
   ╲ │
    ╲│
     1

索引顺序:
  Triangle 1: [0, 1, 2]
  Triangle 2: [0, 3, 1]

📐 平面几何体法线

平面 (Plane) 的法线非常简单,所有顶点共享相同的法线:

c 复制代码
// 生成平面几何体时,添加法线
for (u32 y = 0; y < y_segment_count; ++y) {
    for (u32 x = 0; x < x_segment_count; ++x) {
        // ... 设置位置和纹理坐标 ...

        // 平面位于 XY 平面,法线指向 +Z
        v0->normal = (vec3){0.0f, 0.0f, 1.0f};
        v1->normal = (vec3){0.0f, 0.0f, 1.0f};
        v2->normal = (vec3){0.0f, 0.0f, 1.0f};
        v3->normal = (vec3){0.0f, 0.0f, 1.0f};
    }
}

平面法线可视化:

复制代码
XY 平面:
       Y
       │
       │
       │
       └────── X
      ╱
     ╱
    Z

所有法线指向 +Z:
  ▲ ▲ ▲ ▲
  │ │ │ │
  │ │ │ │
──┴─┴─┴─┴──  平面

🔗 渲染器集成

更新全局 UBO

添加环境光颜色到全局 UBO:

c 复制代码
// engine/src/renderer/vulkan/vulkan_types.inl

typedef struct vulkan_material_shader_global_ubo {
    mat4 projection;     // 64 bytes
    mat4 view;           // 64 bytes
    vec4 ambient_colour; // ← 新增:16 bytes
    mat4 m_reserved0;    // 64 bytes, reserved for future use
} vulkan_material_shader_global_ubo;

传递环境光颜色

从渲染器前端传递环境光颜色:

c 复制代码
// engine/src/renderer/renderer_frontend.c

void renderer_draw_frame(render_packet* packet) {
    if (backend.begin_frame(&backend, packet->delta_time)) {
        // World Renderpass
        backend.begin_renderpass(BUILTIN_RENDERPASS_WORLD);

        // 传递环境光颜色
        vec4 ambient_colour = (vec4){0.25f, 0.25f, 0.25f, 1.0f};  // 25% 强度

        backend.update_global_world_state(
            projection,
            view,
            vec3_zero(),      // camera_position (未使用)
            ambient_colour,   // ← 环境光颜色
            0                 // mode (未使用)
        );

        // 绘制几何体
        for (u32 i = 0; i < packet->geometry_count; ++i) {
            backend.draw_geometry(packet->geometries[i]);
        }

        backend.end_renderpass(BUILTIN_RENDERPASS_WORLD);

        // UI Renderpass
        // ...
    }
}

👁️ 光照效果可视化

光照前后的对比:

复制代码
无光照 (之前的教程):
┌─────────────────────┐
│     ████████        │  立方体看起来"平"
│    ██████████       │  所有面亮度相同
│    ██████████       │  缺乏深度感
│     ████████        │
└─────────────────────┘

有光照 (本教程):
┌─────────────────────┐
│     ████▓▓▓▓        │  立方体有立体感
│    ████▓▓▓▓▓▓       │  不同面亮度不同
│    ████▒▒▒▒▒▒       │  背光面较暗
│     ████▒▒▒▒        │
└─────────────────────┘

光照方向可视化:
    光源 (右上方)
       ╲
        ╲ 光线
         ╲
          ▼
      ┌────┐
     ╱│   ╱│ ← 右面:受光,亮
    ╱ │  ╱ │
   ┌────┐  │
   │  ╲ │  │ ← 前面:部分受光,中等亮度
   │   ╲│  │
   │    └──┘
   └────┘
    ↑
   左面:背光,暗

❓ 常见问题

1. 为什么光照后物体变暗了?

原因:

之前没有光照时,纹理直接显示,亮度为 100%。添加光照后:

复制代码
最终颜色 = 环境光 + 漫反射
         = (0.25 × 纹理) + (0.8 × diffuse_factor × 纹理)

最亮的情况 (diffuse_factor = 1.0):
  = 0.25 + 0.8 = 1.05 ≈ 1.0 (clamp)

一般情况 (diffuse_factor = 0.5):
  = 0.25 + 0.4 = 0.65 (比之前暗)

解决方案:

  1. 增加光源强度:

    glsl 复制代码
    directional_light dir_light = {
        vec3(-0.57735, -0.57735, -0.57735),
        vec4(1.5, 1.5, 1.5, 1.0)  // ← 150% 强度
    };
  2. 增加环境光:

    c 复制代码
    vec4 ambient_colour = (vec4){0.5f, 0.5f, 0.5f, 1.0f};  // 50% 强度
  3. 添加多个光源:

    glsl 复制代码
    // 主光源 + 辅助光源
    vec4 lighting = calculate_directional_light(main_light, normal);
    lighting += calculate_directional_light(fill_light, normal) * 0.3;

2. 为什么有些面完全黑暗?

原因:

当表面背向光源时,dot(normal, -light.direction) < 0,经过 max(..., 0.0) 处理后变为 0,导致漫反射为 0。只剩下环境光。

glsl 复制代码
// 背光面
float diffuse_factor = max(dot(normal, -light.direction), 0.0);
// normal = (1, 0, 0)  (右面)
// -light.direction = (0.57735, 0.57735, 0.57735)  (从右上方来的光)
// dot = 0.57735 > 0 → 受光

// normal = (-1, 0, 0)  (左面)
// dot = -0.57735 < 0 → max(..., 0.0) = 0 → 不受光

可视化:

复制代码
光源从右上方照射:
       光源
        │
        ▼
    ┌───┐
   ╱│  ╱│ ← 右面:亮
  ╱ │ ╱ │
 ┌───┐  │
 │ ╲ │  │ ← 前面:中等亮度
 │  ╲│  │
 │   └──┘
 └───┘
  ↑
 左面:背光,只有环境光

解决方案:

  1. 增加环境光:

    c 复制代码
    vec4 ambient_colour = (vec4){0.3f, 0.3f, 0.3f, 1.0f};
  2. 添加多个光源:

    glsl 复制代码
    // 主光源 + 背光源
    vec4 main_lighting = calculate_directional_light(main_light, normal);
    vec4 back_lighting = calculate_directional_light(back_light, normal) * 0.5;
    return main_lighting + back_lighting;
  3. 使用双面光照 (Two-sided lighting):

    glsl 复制代码
    float diffuse_factor = abs(dot(normal, -light.direction));

3. 法线需要归一化吗?

需要! 法线在插值后可能不再是单位向量:

复制代码
顶点着色器输出 (单位向量):
  v0.normal = (1, 0, 0)  ← 长度 = 1
  v1.normal = (0, 1, 0)  ← 长度 = 1

光栅化插值 (中点):
  interpolated = (0.5, 0.5, 0)
  length = sqrt(0.5² + 0.5²) = 0.707 ≠ 1

如果不归一化:
  dot(interpolated, light_dir)
    会产生错误的结果

解决方案:

在片段着色器中归一化:

glsl 复制代码
vec4 calculate_directional_light(directional_light light, vec3 normal) {
    // 归一化插值后的法线
    vec3 normalized_normal = normalize(normal);

    // 使用归一化的法线计算
    float diffuse_factor = max(dot(normalized_normal, -light.direction), 0.0);

    // ...
}

性能考虑:

  • normalize() 有一定开销 (sqrt + 3 个除法)
  • 但对于正确的光照计算是必需的
  • 现代 GPU 对 normalize() 有硬件优化

4. 为什么立方体的边缘不平滑?

原因:

立方体使用 Flat Shading (平坦着色),每个面的法线相同:

复制代码
Flat Shading (当前实现):
    ┌───┐
   ╱│  ╱│ 每个三角形内部颜色相同
  ╱ │ ╱ │ 边缘有明显的断层
 ┌───┐  │
 │   │  │
 │   │  │
 └───┘  │

原因:
- 共享顶点但法线不同
- 顶点 A 在前面:normal = (0, 0, 1)
- 顶点 A 在右面:normal = (1, 0, 0)
- 无法共享,必须重复顶点

Smooth Shading (平滑着色):

glsl 复制代码
// 计算顶点的平均法线
vec3 vertex_normal = normalize(
    face1_normal +
    face2_normal +
    face3_normal
);

效果:
    ┌───┐
   ╱│  ╱│ 边缘平滑过渡
  ╱ │ ╱ │ 看起来像圆角
 ┌───┐  │
 │   │  │
 │   │  │
 └───┘  │

适用于:球体、圆柱、有机形状
不适用于:立方体、建筑物 (需要保留锐利边缘)

何时使用哪种?

  • Flat Shading: 立方体、建筑物、机械零件
  • Smooth Shading: 球体、人物、有机物体

5. 如何添加多个方向光?

方法 1: 在着色器中硬编码多个光源

glsl 复制代码
// 主光源 (太阳)
directional_light sun = {
    vec3(-0.57735, -0.57735, -0.57735),
    vec4(0.8, 0.8, 0.8, 1.0)
};

// 辅助光源 (天空光)
directional_light sky = {
    vec3(0.0, 1.0, 0.0),  // 从上方照射
    vec4(0.3, 0.3, 0.4, 1.0)  // 微弱的蓝色光
};

void main() {
    vec4 lighting = vec4(0.0);

    // 累加所有光源
    lighting += calculate_directional_light(sun, in_dto.normal);
    lighting += calculate_directional_light(sky, in_dto.normal);

    out_colour = lighting;
}

方法 2: 从 CPU 传递光源数组 (更灵活)

c 复制代码
// C 代码 - 定义光源数组
typedef struct directional_light {
    vec3 direction;
    f32 padding;
    vec4 colour;
} directional_light;

directional_light lights[MAX_LIGHTS];
lights[0] = (directional_light){
    .direction = {-0.57735f, -0.57735f, -0.57735f},
    .colour = {0.8f, 0.8f, 0.8f, 1.0f}
};
lights[1] = (directional_light){
    .direction = {0.0f, 1.0f, 0.0f},
    .colour = {0.3f, 0.3f, 0.4f, 1.0f}
};

// 上传到 UBO
upload_to_ubo(lights, sizeof(lights));
glsl 复制代码
// GLSL 代码 - 接收光源数组
#define MAX_LIGHTS 4

layout(set = 0, binding = 1) uniform lights_ubo {
    int light_count;
    directional_light lights[MAX_LIGHTS];
} scene_lights;

void main() {
    vec4 lighting = vec4(0.0);

    // 遍历所有光源
    for (int i = 0; i < scene_lights.light_count; ++i) {
        lighting += calculate_directional_light(scene_lights.lights[i], in_dto.normal);
    }

    out_colour = lighting;
}

📝 练习

练习 1: 实现可调节的光源方向

任务: 允许用户通过键盘输入旋转光源方向。

c 复制代码
// 应用状态
typedef struct app_light_state {
    f32 light_angle_x;  // 光源在 X 轴的旋转角度
    f32 light_angle_y;  // 光源在 Y 轴的旋转角度
} app_light_state;

// 更新函数
void update_light_direction(app_light_state* state, f32 delta_time) {
    // 键盘输入
    if (input_is_key_down(KEY_LEFT)) {
        state->light_angle_y -= 90.0f * delta_time;  // 旋转速度:90度/秒
    }
    if (input_is_key_down(KEY_RIGHT)) {
        state->light_angle_y += 90.0f * delta_time;
    }
    if (input_is_key_down(KEY_UP)) {
        state->light_angle_x -= 90.0f * delta_time;
    }
    if (input_is_key_down(KEY_DOWN)) {
        state->light_angle_x += 90.0f * delta_time;
    }

    // 计算光源方向
    f32 x_rad = deg_to_rad(state->light_angle_x);
    f32 y_rad = deg_to_rad(state->light_angle_y);

    vec3 light_direction;
    light_direction.x = sin(y_rad) * cos(x_rad);
    light_direction.y = sin(x_rad);
    light_direction.z = cos(y_rad) * cos(x_rad);
    light_direction = vec3_normalized(light_direction);

    // 更新着色器中的光源方向
    // (需要修改着色器以从 UBO 读取光源方向)
}

提示:

  • 修改着色器以从 UBO 读取光源方向,而不是硬编码
  • 添加 UI 显示当前光源角度

练习 2: 实现半球光照 (Hemisphere Lighting)

任务: 实现半球光照,上半球和下半球使用不同的颜色。

glsl 复制代码
// 半球光照结构
struct hemisphere_light {
    vec3 up_direction;  // 上方向 (通常是 (0, 1, 0))
    vec4 sky_color;     // 天空颜色
    vec4 ground_color;  // 地面颜色
};

hemisphere_light hemi_light = {
    vec3(0.0, 1.0, 0.0),
    vec4(0.5, 0.6, 0.7, 1.0),  // 蓝色天空
    vec4(0.3, 0.2, 0.1, 1.0)   // 棕色地面
};

// 半球光照计算
vec4 calculate_hemisphere_light(hemisphere_light light, vec3 normal) {
    // 计算法线与上方向的点积
    float up_factor = dot(normal, light.up_direction) * 0.5 + 0.5;
    // up_factor: 0.0 (向下) → 1.0 (向上)

    // 在天空颜色和地面颜色之间插值
    vec4 hemisphere_color = mix(light.ground_color, light.sky_color, up_factor);

    // 采样纹理
    vec4 diff_samp = texture(diffuse_sampler, in_dto.tex_coord);

    return hemisphere_color * diff_samp;
}

void main() {
    vec4 lighting = calculate_hemisphere_light(hemi_light, in_dto.normal);
    out_colour = lighting;
}

效果:

  • 面向上的表面:蓝色调 (天空光)
  • 面向下的表面:棕色调 (地面反射光)
  • 侧面:两种颜色的混合

练习 3: 实现 Per-Vertex vs Per-Pixel 光照对比

任务: 实现两种光照计算方式的对比。

Per-Vertex Lighting (Gouraud Shading):

glsl 复制代码
// Vertex Shader
layout(location = 1) out vec4 out_color;  // ← 输出颜色而不是法线

void main() {
    vec3 world_normal = mat3(u_push_constants.model) * in_normal;

    // 在顶点着色器中计算光照
    float diffuse_factor = max(dot(world_normal, -dir_light.direction), 0.0);
    vec4 lighting = global_ubo.ambient_colour + (dir_light.colour * diffuse_factor);

    out_color = lighting;  // 输出光照颜色
    gl_Position = ...;
}

// Fragment Shader
layout(location = 1) in vec4 in_color;  // ← 接收插值后的颜色

void main() {
    vec4 diff_samp = texture(diffuse_sampler, in_dto.tex_coord);
    out_colour = in_color * diff_samp;  // 直接使用插值颜色
}

Per-Pixel Lighting (Phong Shading,当前实现):

glsl 复制代码
// Vertex Shader
layout(location = 1) out vec3 out_normal;  // ← 输出法线

void main() {
    out_normal = mat3(u_push_constants.model) * in_normal;
    gl_Position = ...;
}

// Fragment Shader
layout(location = 1) in vec3 in_normal;  // ← 接收插值后的法线

void main() {
    // 在片段着色器中计算光照
    vec3 normalized_normal = normalize(in_normal);
    float diffuse_factor = max(dot(normalized_normal, -dir_light.direction), 0.0);
    vec4 lighting = ambient + (dir_light.colour * diffuse_factor);

    vec4 diff_samp = texture(diffuse_sampler, in_dto.tex_coord);
    out_colour = lighting * diff_samp;
}

对比:

特性 Per-Vertex Per-Pixel
计算位置 顶点着色器 片段着色器
性能 更快 (计算次数少) 更慢 (计算次数多)
质量 较低 (可见三角形) 较高 (平滑)
适用场景 移动平台、远景物体 PC/主机、近景物体

恭喜!你已经掌握了方向光照的实现! 🎉

Tutorial written by 上手实验室

相关推荐
淼淼7634 小时前
Qt工具栏+图页,图元支持粘贴复制,撤销,剪切,移动,删除
开发语言·c++·windows·qt
小新软件园4 小时前
图片转 Excel 不花钱PDF 转 Excel 工具
windows·电脑·开源软件
weisian1514 小时前
入门篇--2-Windows上如何用Conda松管理多个Python版本?
windows·python·conda
openinstall全渠道统计4 小时前
开发者指南:广告投放系统搭建与前后端数据打通全流程
windows·git·oracle·eclipse·sqlite·github
Evan芙4 小时前
Nginx 安装教程(附Nginx编译安装脚本)
windows·nginx·postgresql
怪我冷i4 小时前
wsl Ubuntu切换中科大源
linux·windows·ubuntu·ai编程·ai写作
TeleostNaCl4 小时前
解决微软输入法无法添加多个动态自定义短语的问题
windows·经验分享·微软
郝学胜-神的一滴5 小时前
OpenGL中的glDrawArrays函数详解:从基础到实践
开发语言·c++·程序人生·算法·游戏程序·图形渲染
呼呼突突5 小时前
Unity使用TouchSocket的RPC
unity·rpc·游戏引擎