第八课:材质与光源——让物体真正“活”起来

说实话,前几节课我们一直在跟顶点、纹理、矩阵打交道。你可能会觉得,嗯,画面是有了,但总感觉少了点什么?

少了“质感”。

一个纯色的立方体,和一个看起来像金属、像塑料、像陶瓷的立方体,差别在哪?就在材质和光照。今天我们就来啃这块硬骨头。

8.1 光照模型:为什么我们需要它?

现实世界里,我们能看到物体,是因为光线照到物体表面,然后反射到我们眼睛里。计算机里要模拟这个过程,就得靠光照模型

最经典、最常用的就是冯氏光照模型(Phong Lighting Model)。它把光照拆成三个独立的部分:

  • 环境光(Ambient):就算在阴影里,物体也不是全黑的。环境光模拟了这种“无处不在”的微弱光线。
  • 漫反射(Diffuse):光线照到粗糙表面,向四面八方均匀反射。这是物体颜色的主要来源。
  • 镜面高光(Specular):光线照到光滑表面,产生集中的反射。比如金属上的高光点。

我个人习惯把这三部分加起来,就是最终的颜色:

最终颜色 = 环境光 + 漫反射 + 镜面高光

你想想看,这个模型虽然简单,但组合起来效果已经非常好了。

8.2 材质属性:定义物体“长什么样”

材质,说白了就是告诉OpenGL:这个物体对光线的反应是什么样的。

在冯氏模型中,材质通常由以下几个属性定义:

属性 含义 示例值(金属) 示例值(塑料)
ambient 环境光反射系数 (0.2, 0.2, 0.2) (0.1, 0.1, 0.1)
diffuse 漫反射反射系数 (0.8, 0.6, 0.2) (0.8, 0.8, 0.8)
specular 镜面反射系数 (1.0, 1.0, 1.0) (0.5, 0.5, 0.5)
shininess 高光锐利度 32.0 8.0

我在项目中遇到过一个问题:把金属的shininess设得太高,结果高光点小到几乎看不见。后来才意识到,金属的高光虽然亮,但范围其实比塑料要集中得多。

8.3 点光源 vs 方向光

光源类型不同,计算方式也不同。我们重点讲两种:

8.3.1 方向光

方向光模拟的是太阳光。所有光线都是平行的,方向固定。计算时只需要一个方向向量

它的好处是简单,性能好。坏处是——没有位置,没有衰减。

// 方向光计算(片段着色器)
vec3 lightDir = normalize(-u_LightDirection);
float diff = max(dot(normal, lightDir), 0.0);
vec3 diffuse = diff * lightColor * material.diffuse;

8.3.2 点光源

点光源模拟的是灯泡、蜡烛。它有位置,光线从一点向四周发射。

点光源需要计算衰减:距离越远,光越弱。衰减公式一般是:

float attenuation = 1.0 / (constant + linear * distance + quadratic * distance * distance);

嗯,这里要注意:constant、linear、quadratic三个系数需要调参。我刚开始做的时候直接抄网上的值,结果画面要么太亮要么太暗。后来我习惯先固定constant=1.0,然后根据场景大小调整linear和quadratic。

8.4 多光源组合:让场景丰富起来

实际项目中,很少只用一种光源。比如一个室内场景,可能有:

  • 一个方向光模拟窗外阳光
  • 几个点光源模拟台灯、吊灯
  • 甚至再加一个聚光灯模拟手电筒

多光源组合的核心思路很简单:每个光源单独计算,然后加起来

// 伪代码:多光源组合
vec3 result = vec3(0.0);

// 方向光
result += calcDirectionalLight(normal, viewDir, material);

// 点光源1
result += calcPointLight(lightPos1, normal, fragPos, viewDir, material);

// 点光源2
result += calcPointLight(lightPos2, normal, fragPos, viewDir, material);

// 别忘了环境光
result += ambient * material.ambient;

FragColor = vec4(result, 1.0);

我曾经在一个AR项目中同时用了4个点光源+1个方向光。性能上其实还好,因为每个光源的计算量并不大。但要注意:光源数量越多,着色器越复杂,GPU压力越大。移动端建议控制在3-5个以内。

8.5 实战:在Android中实现材质与多光源

好了,理论说完了,我们动手写代码。这里我给出一个完整的片段着色器示例,它支持材质、一个方向光、两个点光源。

// fragment_shader.glsl
precision mediump float;

struct Material {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    float shininess;
};

struct DirectionalLight {
    vec3 direction;
    vec3 color;
};

struct PointLight {
    vec3 position;
    vec3 color;
    float constant;
    float linear;
    float quadratic;
};

uniform Material u_Material;
uniform DirectionalLight u_DirLight;
uniform PointLight u_PointLights[2];
uniform vec3 u_ViewPos;

varying vec3 v_Normal;
varying vec3 v_FragPos;

vec3 calcDirectionalLight(vec3 normal, vec3 viewDir) {
    vec3 lightDir = normalize(-u_DirLight.direction);
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), u_Material.shininess);
    
    vec3 ambient = u_DirLight.color * u_Material.ambient;
    vec3 diffuse = u_DirLight.color * diff * u_Material.diffuse;
    vec3 specular = u_DirLight.color * spec * u_Material.specular;
    return ambient + diffuse + specular;
}

vec3 calcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir) {
    vec3 lightDir = normalize(light.position - fragPos);
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), u_Material.shininess);
    
    float distance = length(light.position - fragPos);
    float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * distance * distance);
    
    vec3 ambient = light.color * u_Material.ambient;
    vec3 diffuse = light.color * diff * u_Material.diffuse;
    vec3 specular = light.color * spec * u_Material.specular;
    
    return (ambient + diffuse + specular) * attenuation;
}

void main() {
    vec3 normal = normalize(v_Normal);
    vec3 viewDir = normalize(u_ViewPos - v_FragPos);
    
    vec3 result = calcDirectionalLight(normal, viewDir);
    result += calcPointLight(u_PointLights[0], normal, v_FragPos, viewDir);
    result += calcPointLight(u_PointLights[1], normal, v_FragPos, viewDir);
    
    gl_FragColor = vec4(result, 1.0);
}

这段代码里,我把材质和光源都定义成了struct。这样代码清晰,也方便扩展。如果你要加第三个点光源,只需要在uniform数组里加一个元素就行。

核心要点:多光源组合的本质就是“累加”。每个光源独立计算,最后把结果加起来。但要注意,环境光不要重复计算——通常只加一次。

8.6 知识体系总览

下面这张图,是我自己整理的光照与材质知识结构。你可以把它当作一个“地图”,学完这节课后对照着检查自己掌握了哪些。

材质与光源知识体系 光照模型 环境光 Ambient 漫反射 Diffuse 镜面高光 Specular 材质属性 ambient / diffuse specular / shininess 光源类型 方向光(平行光) 点光源(有衰减) 聚光灯(进阶) 多光源组合 每个光源独立计算 结果累加 注意环境光只加一次 最终颜色 = 环境光 + 漫反射 + 镜面高光

我的小技巧:调试光照时,可以先只开漫反射,确认法线方向正确。然后再加环境光和镜面高光。这样出问题了容易定位。

注意:法线一定要在片段着色器里重新归一化(normalize)。因为顶点着色器插值出来的法线长度会变化,不归一化的话光照效果会出错。我曾经在这个坑里浪费了半天时间。

好了,这节课的内容就到这里。材质和光源是OpenGL里最有趣的部分之一。你想想看,同样的模型,换一套材质参数、换几个光源位置,出来的效果天差地别。这就是图形学的魅力所在。

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