你好,我是月影。今天,我们接着来讲,怎么模拟光照。

上节课,我们讲了四种光照的漫反射模型。实际上,因为物体的表面材质不同,反射光不仅有漫反射,还有镜面反射。

什么是镜面反射呢?如果若干平行光照射在表面光滑的物体上,反射出来的光依然平行,这种反射就是镜面反射。镜面反射的性质是,入射光与法线的夹角等于反射光与法线的夹角。

越光滑的材质,它的镜面反射效果也就越强。最直接的表现就是物体表面会有闪耀的光斑,也叫镜面高光。但并不是所有光都能产生镜面反射,我们上节课讲的四种光源中,环境光因为没有方向,所以不参与镜面反射。剩下的平行光、点光源、聚光灯这三种光源,都是能够产生镜面反射的有向光。

那么今天,我们就来讨论一下如何实现镜面反射,然后将它和上节课的漫反射结合起来,就可以实现标准的光照模型,也就是Phong反射模型了,从而能让我们实现的可视化场景更加接近于自然界的效果。

如何实现有向光的镜面反射?

首先,镜面反射需要同时考虑光的入射方向以及相机也就是观察者所在的方向。

接着,我们再来说说怎么实现镜面反射效果,一般来说需要4个步骤。

第一步,求出反射光线的方向向量。这里我们以点光源为例,要求出反射光的方向,我们可以直接使用GLSL的内置函数reflect,这个函数能够返回一个向量相对于某个法向量的反射向量,正好就是我们要的镜面反射结果。

// 求光源与点坐标的方向向量
vec3 dir = (viewMatrix * vec4(pointLightPosition, 1.0)).xyz - vPos;

// 归一化
dir = normalize(dir);

// 求反射向量
vec3 reflectionLight = reflect(-dir, vNormal);

第二步,我们要根据相机位置计算视线与反射光线夹角的余弦,用到原理是向量的点乘。

vec3 eyeDirection = vCameraPos - vPos;
eyeDirection = normalize(eyeDirection);
// 与视线夹角余弦
float eyeCos = max(dot(eyeDirection, reflectionLight), 0.0);

第三步,我们使用系数和指数函数设置镜面反射强度。指数越大,镜面越聚焦,高光的光斑范围就越小。这里,我们指数取50.0,系数取2.0。系数能改变反射亮度,系数越大,反射的亮度就越高。

float specular = 2.0 *  pow(eyeCos, 50.0);

最后,我们将漫反射和镜面反射结合起来,就会让距离光源近的物体上,形成光斑。

// 合成颜色
gl_FragColor.rgb = specular + (ambientLight + diffuse) * materialReflection;
gl_FragColor.a = 1.0;

上面的代码是以点光源为例来实现的光斑,其实只要是有向光,都可以用同样的方法求出镜面反射,只不过对应的入射光方向计算有所不同,也就是着色器代码中的dir变量计算方式不一样。 你可以利用我上节课讲的内容,自己动手试试。

如何实现完整的Phong反射模型?

那在自然界中,除了环境光以外,其他每种光源在空间中都可以存在不止一个,而且因为几何体材质不同,物体表面也可能既出现漫反射,又出现镜面反射。

可能出现的情况这么多,分析和计算起来也会非常复杂。为了方便处理,我们可以把多种光源和不同材质结合起来,形成标准的反射模型,这一模型被称为Phong反射模型

Phong反射模型的完整公式如下:

$$
I_{\mathrm{p}}=k_{\mathrm{a}} i_{\mathrm{a}}+\sum_{m \in \text { lights }}\left(k_{\mathrm{d}}\left(\hat{L}_{m} \cdot \hat{N}\right) i_{m, \mathrm{d}}+k_{\mathrm{s}}\left(\hat{R}_{m} \cdot \hat{V}\right)^{\alpha} i_{m, \mathrm{s}}\right)
$$

公式里的$k_{\mathrm{a}}$、$k_{\mathrm{d}}$和$k_{\mathrm{s}}$分别对应环境反射系数、漫反射系数和镜面反射系数。$\hat{L}_{m}$是入射光,$N$是法向量,$\hat{R}_{m}$是反射光,$V$是视线向量。$i$是强度,漫反射和镜面反射的强度可考虑因为距离的衰减。$⍺$是和物体材质有关的常量,决定了镜面高光的范围。

根据上面的公式,我们把多个光照的计算结果相加,就能得到光照下几何体的最终颜色了。不过,这里的Phong反射模型实际上是真实物理世界光照的简化模型,因为它只考虑光源的光作用于物体,没有考虑各个物体之间的反射光。所以我们最终实现出的效果也只是自然界效果的一种近似,不过这种近似也高度符合真实情况了。

在一般的图形库或者图形框架中,会提供符合Phong反射模型的物体材质,比如ThreeJS中,就支持各种光源和反射材质。

下面,我们来实现一下完整的Phong反射模型。它可以帮助你对这个模型有更深入的理解,让你以后使用ThreeJS等其他图形库,也能够更加得心应手。整个过程分为三步:定义光源模型、定义几何体材质和实现着色器。

1. 定义光源模型

我们先来定义光源模型对象。环境光比较特殊,我们将它单独抽象出来,放在一个ambientLight的属性中,而其他的光源一共有5个属性与材质无关,我列了一张表放在了下面。

这样,我们就可以定义一个Phong类。这个类由一个环境光属性和其他三种光源的集合组合而成,表示一个可以添加和删除光源的对象。它的主要作用是添加和删除光源,并把光源的属性通过uniforms访问器属性转换成对应的uniform变量,主要的代码如下:

class Phong {
  constructor(ambientLight = [0.5, 0.5, 0.5]) {
    this.ambientLight = ambientLight;
    this.directionalLights = new Set();
    this.pointLights = new Set();
    this.spotLights = new Set();
  }

  addLight(light) {
    const {position, direction, color, decay, angle} = light;
    if(!position && !direction) throw new TypeError('invalid light');
    light.color = color || [1, 1, 1];
    if(!position) this.directionalLights.add(light);
    else {
      light.decay = decay || [0, 0, 1];
      if(!angle) {
        this.pointLights.add(light);
      } else {
        this.spotLights.add(light);
      }
    }
  }

  removeLight(light) {
    if(this.directionalLights.has(light)) this.directionalLights.delete(light);
    else if(this.pointLights.has(light)) this.pointLights.delete(light);
    else if(this.spotLights.has(light)) this.spotLights.delete(light);
  }

  get uniforms() {
    const MAX_LIGHT_COUNT = 16; // 最多每种光源设置16个
    this._lightData = this._lightData || {};
    const lightData = this._lightData;

    lightData.directionalLightDirection = lightData.directionalLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.directionalLightColor = lightData.directionalLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};

    lightData.pointLightPosition = lightData.pointLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.pointLightColor = lightData.pointLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.pointLightDecay = lightData.pointLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};

    lightData.spotLightDirection = lightData.spotLightDirection || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightPosition = lightData.spotLightPosition || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightColor = lightData.spotLightColor || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightDecay = lightData.spotLightDecay || {value: new Float32Array(MAX_LIGHT_COUNT * 3)};
    lightData.spotLightAngle = lightData.spotLightAngle || {value: new Float32Array(MAX_LIGHT_COUNT)};

    [...this.directionalLights].forEach((light, idx) => {
      lightData.directionalLightDirection.value.set(light.direction, idx * 3);
      lightData.directionalLightColor.value.set(light.color, idx * 3);
    });

    [...this.pointLights].forEach((light, idx) => {
      lightData.pointLightPosition.value.set(light.position, idx * 3);
      lightData.pointLightColor.value.set(light.color, idx * 3);
      lightData.pointLightDecay.value.set(light.decay, idx * 3);
    });

    [...this.spotLights].forEach((light, idx) => {
      lightData.spotLightPosition.value.set(light.position, idx * 3);
      lightData.spotLightColor.value.set(light.color, idx * 3);
      lightData.spotLightDecay.value.set(light.decay, idx * 3);
      lightData.spotLightDirection.value.set(light.direction, idx * 3);
      lightData.spotLightAngle.value[idx] = light.angle;
    });

    return {
      ambientLight: {value: this.ambientLight},
      ...lightData,
    };
  }
}

有了这个类之后,我们就可以创建并添加各种光源了。我在下面的代码中,添加了一个平行光和两个点光源,你可以看看。

const phong = new Phong();
// 添加一个平行光
phong.addLight({
  direction: [-1, 0, 0],
});
// 添加两个点光源
phong.addLight({
  position: [-3, 3, 0],
  color: [1, 0, 0],
});

phong.addLight({
  position: [3, 3, 0],
  color: [0, 0, 1],
});

2. 定义几何体材质

定义完光源之后,我们还需要定义几何体的材质(material),因为几何体材质决定了光反射的性质。

在前面的课程里,我们已经了解了一种与几何体材质有关的变量,即物体的反射率(MaterialReflection)。那在前面计算镜面反射的公式,float specular = 2.0 * pow(eyeCos, 50.0);中也有两个常量2.0和50.0,把它们也提取出来,我们就能得到两个新的变量。其中,2.0对应specularFactor,表示镜面反射强度,50.0指的是shininess,表示镜面反射的光洁度。

这样,我们就有了3个与材质有关的变量,分别是matrialReflection (材质反射率)、specularFactor (镜面反射强度)、以及shininess (镜面反射光洁度)。

然后,我们可以创建一个Matrial类,来定义物体的材质。与光源类相比,这个类非常简单,只是设置这三个参数,并通过uniforms访问器属性,获得它的uniform数据结构形式。

class Material {
  constructor(reflection, specularFactor = 0, shininess = 50) {
    this.reflection = reflection;
    this.specularFactor = specularFactor;
    this.shininess = shininess;
  }

  get uniforms() {
    return {
      materialReflection: {value: this.reflection},
      specularFactor: {value: this.specularFactor},
      shininess: {value: this.shininess},
    };
  }
}

那么,我们就可以创建matrial对象了。这里,我一共创建4个matrial对象,分别对应要显示的四个几何体的材质。

const matrial1 = new Material(new Color('#0000ff'), 2.0);
const matrial2 = new Material(new Color('#ff00ff'), 2.0);
const matrial3 = new Material(new Color('#008000'), 2.0);
const matrial4 = new Material(new Color('#ff0000'), 2.0);

有了phong对象和matrial对象,我们就可以给几何体创建WebGL程序了。那我们就使用上面四个WebGL程序,来创建真正的几何体网格,并将它们渲染出来吧。具体代码如下:

const program1 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial1.uniforms,
    ...phong.uniforms,
  },
});
const program2 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial2.uniforms,
    ...phong.uniforms,
  },
});
const program3 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial3.uniforms,
    ...phong.uniforms,
  },
});
const program4 = new Program(gl, {
  vertex,
  fragment,
  uniforms: {
    ...matrial4.uniforms,
    ...phong.uniforms,
  },
});

3. 实现着色器

接下来,我们重点看一下,支持phong反射模型的片元着色器代码是怎么实现的。这个着色器代码比较复杂,我们一段一段来看。

首先,我们来看光照相关的uniform变量的声明。这里,我们声明了vec3和float数组,数组的大小为16。这样,对于每一种光源,我们都可以支持16个。

#define MAX_LIGHT_COUNT 16
uniform mat4 viewMatrix;

uniform vec3 ambientLight;
uniform vec3 directionalLightDirection[MAX_LIGHT_COUNT];
uniform vec3 directionalLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightColor[MAX_LIGHT_COUNT];
uniform vec3 pointLightPosition[MAX_LIGHT_COUNT];
uniform vec3 pointLightDecay[MAX_LIGHT_COUNT];
uniform vec3 spotLightColor[MAX_LIGHT_COUNT];
uniform vec3 spotLightDirection[MAX_LIGHT_COUNT];
uniform vec3 spotLightPosition[MAX_LIGHT_COUNT];
uniform vec3 spotLightDecay[MAX_LIGHT_COUNT];
uniform float spotLightAngle[MAX_LIGHT_COUNT];

uniform vec3 materialReflection;
uniform float shininess;
uniform float specularFactor;

接下来,我们实现计算phong反射模型的主题逻辑。事实上,处理平行光、点光源、聚光灯的主体逻辑类似,都是循环处理每个光源,再计算入射光方向,然后计算漫反射以及镜面反射,最终将结果返回。

float getSpecular(vec3 dir, vec3 normal, vec3 eye) {
  vec3 reflectionLight = reflect(-dir, normal);
  float eyeCos = max(dot(eye, reflectionLight), 0.0);
  return specularFactor *  pow(eyeCos, shininess);
}
      
vec4 phongReflection(vec3 pos, vec3 normal, vec3 eye) {
  float specular = 0.0;
  vec3 diffuse = vec3(0);
  
  // 处理平行光
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 dir = directionalLightDirection[i];
    if(dir.x == 0.0 && dir.y == 0.0 && dir.z == 0.0) continue;
    vec4 d = viewMatrix * vec4(dir, 0.0);
    dir = normalize(-d.xyz);
    float cos = max(dot(dir, normal), 0.0);
    diffuse += cos * directionalLightColor[i];
    specular += getSpecular(dir, normal, eye);
  }

  // 处理点光源
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 decay = pointLightDecay[i];
    if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;
    vec3 dir = (viewMatrix * vec4(pointLightPosition[i], 1.0)).xyz - pos;
    float dis = length(dir);
    dir = normalize(dir);
    float cos = max(dot(dir, normal), 0.0);
    float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
    diffuse += d * cos * pointLightColor[i];
    specular += getSpecular(dir, normal, eye);
  }

  // 处理聚光灯
  for(int i = 0; i < MAX_LIGHT_COUNT; i++) {
    vec3 decay = spotLightDecay[i];
    if(decay.x == 0.0 && decay.y == 0.0 && decay.z == 0.0) continue;

    vec3 dir = (viewMatrix * vec4(spotLightPosition[i], 1.0)).xyz - pos;
    float dis = length(dir);
    dir = normalize(dir);

    // 聚光灯的朝向
    vec3 spotDir = (viewMatrix * vec4(spotLightDirection[i], 0.0)).xyz;
    // 通过余弦值判断夹角范围
    float ang = cos(spotLightAngle[i]);
    float r = step(ang, dot(dir, normalize(-spotDir)));

    float cos = max(dot(dir, normal), 0.0);
    float d = min(1.0, 1.0 / (decay.x * pow(dis, 2.0) + decay.y * dis + decay.z));
    diffuse += r * d * cos * spotLightColor[i];
    specular += r * getSpecular(dir, normal, eye);
  }

  return vec4(diffuse, specular);
}

最后,我们在main函数中,调用phongReflection函数来合成颜色。代码如下:

void main() {
  vec3 eyeDirection = normalize(vCameraPos - vPos);
  vec4 phong = phongReflection(vPos, vNormal, eyeDirection);

  // 合成颜色
  gl_FragColor.rgb = phong.w + (phong.xyz + ambientLight) * materialReflection;
  gl_FragColor.a = 1.0;
}

最终呈现的视觉效果如下图所示:

你注意一下上图右侧的球体。因为我们一共设置了3个光源,一个平行光、两个点光源,它们都能够产生镜面反射。所以,这些光源叠加在一起后,这个球体就呈现出3个镜面高光。

Phong反射模型的局限性

虽然,phong反射模型已经比较接近于真实的物理模型,不过它仍然是真实模型的一种近似。因为它没有考虑物体反射光对其他物体的影响,也没有考虑物体对光线遮挡产生的阴影。

当然,我们可以完善这个模型。比如,将物体本身反射光(主要是镜面反射光)对其他物体的影响纳入到模型中。另外,我们也要考虑物体的阴影。当我们把这些因素更多地考虑进去的时候,我们的模型就会更加接近真实世界的物理模型。

当我们渲染3D图形的时候,要呈现越接近真实的效果,往往要考虑更多的参数,因此所需的计算量也越大,那我们就需要有更强的渲染能力,比如,更好的显卡,更快的CPU和GPU,并且也需要我们尽可能地优化计算的性能。

但是,有很多时候,我们需要在细节和性能上做出平衡和取舍。那性能优化的部分,也是我们课程的重点,我会在性能篇详细来讲。这节课,我们就重点关注反射模型,总结出完整的Phong反射模型就可以了。

要点总结

今天,我们把环境光、平行光、点光源、聚光灯这四种光源整合,并且在上节课讲的漫反射的基础上,添加了镜面反射,形成了完整的Phong反射模型。在这里,我们实现的着色器代码能够结合四种光源的效果,除了环境光外,每种光源还可以设置多个。

在Phong反射模型中,光照在物体上的最终效果,由各个光源的性质(参数)和物体的表面材质共同决定。

Phong反射模型也只是真实世界的一种近似,因为我们并没有考虑物体之间反射光的相互影响,也没有考虑光线的遮挡。如果把这些因素考虑进去,那我们的模型可以更接近真实世界了。

小试牛刀

我们知道,平行光、点光源和聚光灯是三种常见的方向光,但真实世界还有其他的方向光,比如探照灯,它是一种有范围的平行光,类似于聚光灯,但又不完全一样。你能给物体实现探照灯效果吗?

这里,我先把需要用到的参数告诉你,包括光源方向searchLightDirection、光源半径searchLightRadius、光源位置searchLightPosition、光照颜色searchLightColor。你可以用OGL实现探照灯效果,然后把对应的着色器代码写在留言区。

而且,探照灯的光照截面不一定是圆形,也可以是其他图形,比如三角形、菱形、正方形,你也可以试着让它支持不同的光照截面。

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见!


源码

课程中完整代码详见GitHub仓库

推荐阅读

Phong反射模型简介

评论