第七课:光照模型入门——冯氏光照模型之漫反射

各位同学,今天我们来聊聊光照。说实话,光照是让3D场景“活”起来的关键一步。没有光照,你的模型就是一堆扁平的多边形,毫无生气。

我个人最早接触OpenGL时,第一个被震撼到的效果就是加上漫反射光照的那一刻。原本灰蒙蒙的立方体,突然有了立体感,像被真实的光线照亮了一样。嗯,那种感觉,至今难忘。

为什么需要光照模型?

现实世界中,我们能看到物体,是因为光线照射到物体表面,然后反射到我们的眼睛里。计算机要模拟这个过程,就得有一套数学公式——这就是光照模型。

冯氏光照模型(Phong Lighting Model)是最经典、最基础的光照模型。它把光照拆成三个分量:

  • 环境光(Ambient):模拟场景中无处不在的微弱光线,让物体不至于全黑
  • 漫反射(Diffuse):模拟光线照射到粗糙表面后向各个方向均匀反射
  • 镜面光(Specular):模拟光滑表面的高光反射

今天我们先聚焦漫反射。为什么?因为它是三个分量中最直观、也最出效果的。你想想看,一个物体被光照射,正面亮、侧面暗、背面全黑——这就是漫反射在起作用。

核心概念:漫反射的强度取决于光线方向与表面法线方向的夹角。夹角越小,光照越强;夹角越大,光照越弱。

漫反射的数学原理

说白了,漫反射的计算就一个公式:

diffuse = max(dot(normal, lightDir), 0.0) * lightColor

这里:

  • normal 是表面法线(单位向量)
  • lightDir 是从表面指向光源的方向(单位向量)
  • dot 是点积运算,计算两个向量的夹角余弦值
  • max(..., 0.0) 确保结果不为负数(背面不发光)

我在项目中遇到过一个问题:法线没有归一化,结果光照效果忽明忽暗,排查了半天才发现是向量长度不对。所以,记得一定要归一化!

在片段着色器中实现漫反射

好,理论说完了,我们直接上代码。下面是一个完整的片段着色器,实现了漫反射光照:

// 片段着色器 - 漫反射光照
#version 300 es
precision mediump float;

// 从顶点着色器传来的数据
in vec3 vNormal;    // 插值后的法线
in vec3 vFragPos;   // 插值后的片段位置

// 统一变量
uniform vec3 uLightPos;   // 光源位置
uniform vec3 uLightColor; // 光源颜色
uniform vec3 uViewPos;    // 观察者位置(暂时不用)
uniform vec3 uObjectColor;// 物体颜色

out vec4 fragColor;

void main() {
    // 1. 归一化法线
    vec3 normal = normalize(vNormal);
    
    // 2. 计算光线方向(从片段指向光源)
    vec3 lightDir = normalize(uLightPos - vFragPos);
    
    // 3. 计算漫反射强度
    float diff = max(dot(normal, lightDir), 0.0);
    
    // 4. 计算最终颜色
    vec3 diffuse = diff * uLightColor * uObjectColor;
    
    // 5. 加上一点环境光,避免背面全黑
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * uLightColor * uObjectColor;
    
    vec3 result = ambient + diffuse;
    fragColor = vec4(result, 1.0);
}

这段代码我解释一下关键点:

  1. 法线归一化:顶点着色器传过来的法线是插值后的,长度可能不是1,必须重新归一化
  2. 光线方向:用光源位置减去片段位置,得到从片段指向光源的向量
  3. 点积取正:用max(dot, 0.0)确保背光面为0,不会出现负光照
  4. 环境光:我习惯加一个很小的环境光(0.1倍),这样背光面不会完全漆黑

小技巧:调试时可以把环境光强度设成0.5,这样你能清楚看到模型的轮廓。等漫反射效果调好了,再改回0.1。

顶点着色器的配合

片段着色器需要法线和片段位置,这些数据从哪来?从顶点着色器传过来。看代码:

// 顶点着色器
#version 300 es
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;

out vec3 vNormal;
out vec3 vFragPos;

void main() {
    // 计算世界空间中的位置
    vec4 worldPos = uModel * vec4(aPos, 1.0);
    vFragPos = worldPos.xyz;
    
    // 转换法线(使用法线矩阵)
    mat3 normalMatrix = transpose(inverse(mat3(uModel)));
    vNormal = normalMatrix * aNormal;
    
    gl_Position = uProjection * uView * worldPos;
}

这里有个坑,我必须要提醒你:

注意:法线不能直接用模型矩阵变换!如果模型有非均匀缩放(比如X轴拉伸2倍,Y轴不变),直接变换会导致法线方向错误。必须用法线矩阵——也就是模型矩阵的逆转置矩阵的左上角3x3部分。

我曾经在这个问题上栽过跟头。一个简单的立方体,旋转后光照效果完全不对,阴影位置飘忽不定。查了两天资料才发现是法线变换的问题。嗯,从那以后,我写光照代码第一件事就是检查法线矩阵。

知识体系总览

下面这张图总结了漫反射光照的完整流程:

漫反射光照实现流程 顶点数据 位置 + 法线 顶点着色器 计算世界坐标 + 法线变换 光栅化 插值法线和位置 片段着色器(核心计算) ① 归一化法线:normalize(vNormal) ② 计算光线方向:normalize(lightPos - fragPos) ③ 点积计算强度:max(dot(normal, lightDir), 0.0) ④ 合成颜色:diffuse * lightColor * objectColor 输出最终颜色

常见问题与避坑指南

根据我的经验,初学者在实现漫反射时最容易遇到这几个问题:

问题 现象 解决方案
法线未归一化 光照强度不均匀,闪烁 在片段着色器中调用normalize()
法线变换错误 旋转模型时光照异常 使用法线矩阵(逆转置矩阵)
光线方向反了 背光面反而亮 检查lightPos - fragPos的方向
忘记环境光 背光面全黑 加一个0.1~0.2的环境光分量

调试技巧:如果你不确定光照计算是否正确,可以先把漫反射颜色直接设为法线方向的可视化(fragColor = vec4(normal * 0.5 + 0.5, 1.0))。这样你能直观看到法线方向是否正确,排除法线问题后再调光照。

好了,漫反射光照就讲到这里。代码不多,但背后的原理值得反复琢磨。你动手试试,把一个立方体加上漫反射光照,旋转它,观察不同面的亮度变化——相信我,那种从平面到立体的转变,会让你上瘾的。


公众号:蓝海资料掘金营,微信deep3321