上一篇:在UI渲染通道中绘制 | 下一篇:法线贴图 | 返回目录
📚 快速导航
目录
- 简介
- 学习目标
- 光照基础
- 光照模型概述
- 环境光
- 漫反射光
- 方向光
- 法线向量
- 什么是法线
- 法线的重要性
- 法线变换
- 顶点着色器更新
- 输入布局修改
- 法线变换
- 数据传递
- 片段着色器更新
- 方向光结构
- Lambert漫反射
- 光照计算
- 立方体几何体生成
- 立方体顶点布局
- 每面法线计算
- 索引生成
- 平面几何体法线
- 渲染器集成
- 光照效果可视化
- 常见问题
- 练习
📖 简介
在之前的教程中,我们实现了纹理映射,但渲染的3D物体看起来很"平"(flat),缺乏深度感和立体感。这是因为我们还没有实现光照(Lighting)。
本教程将实现最基础的光照模型:方向光照(Directional Lighting),包括:
- 环境光(Ambient Light):场景的基础照明
- 漫反射(Diffuse):根据光照方向和表面朝向计算的光照
- Lambert 漫反射模型:一个简单但有效的光照公式
通过添加光照,3D物体将展现出真实的立体感和深度,为更高级的渲染技术(如镜面高光、法线贴图、阴影)奠定基础。
🎯 学习目标
| 目标 | 描述 |
|---|---|
| 理解光照基础 | 掌握环境光、漫反射、方向光的概念 |
| 实现法线变换 | 正确变换法线向量以适应模型变换 |
| Lambert漫反射 | 实现经典的Lambert光照模型 |
| 立方体几何体 | 生成带法线的立方体 |
| 光照着色器 | 更新着色器以计算光照 |
💡 光照基础
光照模型概述
真实世界的光照非常复杂,但我们可以用简化的模型近似它:
完整的 Phong 光照模型: ┌─────────────────────────────────┐ │ Final Color = Ambient + │ │ Diffuse + │ │ Specular │ └─────────────────────────────────┘ 本教程实现 (简化版): ┌─────────────────────────────────┐ │ Final Color = Ambient + │ │ Diffuse │ └─────────────────────────────────┘ 未来教程将添加: - Specular (镜面高光) - Normal Mapping (法线贴图) - Shadows (阴影) - Global Illumination (全局光照)环境光
环境光(Ambient Light) 是场景中的基础照明,模拟间接光照:
// 环境光是常数,所有点接收相同的环境光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) 模拟无限远处的光源 (如太阳):
// 方向光结构structdirectional_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 (平滑着色) │ │ - 每个顶点使用插值的法线 │ │ │ │ ▲ │ │ ╱ ╲ │ │ ╱ ╲ │ │ ─────── │ │ │ │ 效果:平滑曲面,适合球体、圆柱 │ └────────────────────────────────┘法线变换
当模型变换 (缩放、旋转、平移) 时,法线也需要变换:
// ❌ 错误:直接使用模型矩阵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 倍): │ 法线 ↑ (错误!) │ 应该倾斜 ↗ ──┴──── 矩形 使用法线矩阵后: ↗ 法线 (正确!) ╱ ────── 矩形🎨 顶点着色器更新
输入布局修改
添加法线作为顶点输入:
// 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 | 位置变化 |
法线变换
法线从模型空间变换到世界空间:
// 提取模型矩阵的旋转部分 (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) 将数据传递到片段着色器:
// 顶点着色器输出 (每个顶点) 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 // 插值后的法线 (需要重新归一化!)🎨 片段着色器更新
方向光结构
定义方向光结构:
// 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 漫反射计算:
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 个顶点:
// engine/src/systems/geometry_system.cgeometry_configgeometry_system_generate_cube_config(f32 width,f32 height,f32 depth,f32 tile_x,f32 tile_y,constchar*name,constchar*material_name){geometry_config config;config.vertex_size=sizeof(vertex_3d);config.vertex_count=4*6;// 4 verts per side, 6 sidesconfig.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 sidesconfig.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每面法线计算
每个面的所有顶点共享相同的法线:
// 前面 (面向 +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) |
索引生成
为每个面生成索引:
// 为 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) 的法线非常简单,所有顶点共享相同的法线:
// 生成平面几何体时,添加法线for(u32 y=0;y<y_segment_count;++y){for(u32 x=0;x<x_segment_count;++x){// ... 设置位置和纹理坐标 ...// 平面位于 XY 平面,法线指向 +Zv0->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:
// engine/src/renderer/vulkan/vulkan_types.inltypedefstructvulkan_material_shader_global_ubo{mat4 projection;// 64 bytesmat4 view;// 64 bytesvec4 ambient_colour;// ← 新增:16 bytesmat4 m_reserved0;// 64 bytes, reserved for future use}vulkan_material_shader_global_ubo;传递环境光颜色
从渲染器前端传递环境光颜色:
// engine/src/renderer/renderer_frontend.cvoidrenderer_draw_frame(render_packet*packet){if(backend.begin_frame(&backend,packet->delta_time)){// World Renderpassbackend.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 (比之前暗)解决方案:
增加光源强度:
directional_light dir_light = { vec3(-0.57735, -0.57735, -0.57735), vec4(1.5, 1.5, 1.5, 1.0) // ← 150% 强度 };增加环境光:
vec4 ambient_colour=(vec4){0.5f,0.5f,0.5f,1.0f};// 50% 强度添加多个光源:
// 主光源 + 辅助光源 vec4 lighting = calculate_directional_light(main_light, normal); lighting += calculate_directional_light(fill_light, normal) * 0.3;
原因:
当表面背向光源时,dot(normal, -light.direction) < 0,经过max(..., 0.0)处理后变为 0,导致漫反射为 0。只剩下环境光。
// 背光面 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 → 不受光可视化:
光源从右上方照射: 光源 │ ▼ ┌───┐ ╱│ ╱│ ← 右面:亮 ╱ │ ╱ │ ┌───┐ │ │ ╲ │ │ ← 前面:中等亮度 │ ╲│ │ │ └──┘ └───┘ ↑ 左面:背光,只有环境光解决方案:
增加环境光:
vec4 ambient_colour=(vec4){0.3f,0.3f,0.3f,1.0f};添加多个光源:
// 主光源 + 背光源 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;使用双面光照 (Two-sided lighting):
float diffuse_factor = abs(dot(normal, -light.direction));
需要!法线在插值后可能不再是单位向量:
顶点着色器输出 (单位向量): 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) 会产生错误的结果解决方案:
在片段着色器中归一化:
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()有硬件优化
原因:
立方体使用Flat Shading(平坦着色),每个面的法线相同:
Flat Shading (当前实现): ┌───┐ ╱│ ╱│ 每个三角形内部颜色相同 ╱ │ ╱ │ 边缘有明显的断层 ┌───┐ │ │ │ │ │ │ │ └───┘ │ 原因: - 共享顶点但法线不同 - 顶点 A 在前面:normal = (0, 0, 1) - 顶点 A 在右面:normal = (1, 0, 0) - 无法共享,必须重复顶点Smooth Shading (平滑着色):
// 计算顶点的平均法线 vec3 vertex_normal = normalize( face1_normal + face2_normal + face3_normal ); 效果: ┌───┐ ╱│ ╱│ 边缘平滑过渡 ╱ │ ╱ │ 看起来像圆角 ┌───┐ │ │ │ │ │ │ │ └───┘ │ 适用于:球体、圆柱、有机形状 不适用于:立方体、建筑物 (需要保留锐利边缘)何时使用哪种?
- Flat Shading: 立方体、建筑物、机械零件
- Smooth Shading: 球体、人物、有机物体
方法 1: 在着色器中硬编码多个光源
// 主光源 (太阳) 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 代码 - 定义光源数组typedefstructdirectional_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}};// 上传到 UBOupload_to_ubo(lights,sizeof(lights));// 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: 实现可调节的光源方向任务:允许用户通过键盘输入旋转光源方向。
// 应用状态typedefstructapp_light_state{f32 light_angle_x;// 光源在 X 轴的旋转角度f32 light_angle_y;// 光源在 Y 轴的旋转角度}app_light_state;// 更新函数voidupdate_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 显示当前光源角度
任务:实现半球光照,上半球和下半球使用不同的颜色。
// 半球光照结构 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; }效果:
- 面向上的表面:蓝色调 (天空光)
- 面向下的表面:棕色调 (地面反射光)
- 侧面:两种颜色的混合
任务:实现两种光照计算方式的对比。
Per-Vertex Lighting (Gouraud Shading):
// 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,当前实现):
// 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 上手实验室