OpenGL 3D渲染技术:光照原理

12,825 阅读8分钟

大家好,我是程序员kenney,这是我的OpenGL ES 高级进阶系列文章,在我的github上有一个与本系列文章对应的项目,欢迎关注,链接:github.com/kenneycode/…

今天给大家介绍OpenGL的光照原理,一般在3D渲染中会用到。

首先我们来了解一下物体的颜色是什么,物体会吸收某些颜色的光,没有被吸收的光会反射出来,就是物体的颜色:

基于上述原理,假设一个物体的颜色是objectColor=(a, b, c),光的颜色是lightColor=(x, y, z),那么光照射到这个物体上,我们看到的颜色就是:(a*x, b*y, c*z)

我举了些例子:

这里有些看起来好像比较违背我们的常识,比如第一个,绿光照到一个红色的物体上,得到黑色???你应该会说,现实生活中,不是应该看起来又红又绿吗?

我们生活中所看到的红色物体,并不是只反射红色,只是反射光中大部分是红色,如果你能找到一个只反射纯红色这一种颜色的物体,以及一个颜色极纯的绿色光源,并且在一个没有其它任何光的环境中,用这个纯绿色光去照射这个只反射纯红色的物体上,你应该能看它不会反射绿色光,于是没有光线反射出来,它就是黑色的。

那么在程序中,就是可以定义出这样的只反射红色的物体,同样的光的颜色也是可以精确定义。

下面开始讲光照的计算原理,会涉及到OpenGL的矩阵变换原理,不熟悉的朋友可以参见我的另一篇文章:《OpenGL ES 高级进阶:坐标系及矩阵变换》 ,务必要掌握矩阵变换原理,不然很难理解后文的光照计算。

我们先来构建一个没有光照的场景,我在世界坐标系原点的渲染了一个黄色单位立方体:

下面介绍一个经典的光照模型,Phong光照模型。

Phong光照模型的主要结构由3个分量组成:

环境光照(Ambient Lighting):就是所处的环境中,就算是很黑的,也多少会有一些光,比如月光等,这就会给物体照上一个基础的光,让它不会是黑的。

漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响,它是冯氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。

镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。

先看环境光的计算:

// fragment shader
void main()
{
    float ambientStrength = 0.2;
    vec3 ambient = ambientStrength * lightColor;
    vec3 result = ambient * objectColor;
    fragColor = vec4(result, 1.0);
}

很简单,就是直接用环境光的颜色乘以物体颜色,通常还会加一个强度系数,下面就是强度为0.2和0.5时的效果:

接下来看漫反射光照,开始有点复杂了,漫反射光照会考虑面的光线反射,越是垂直光线方向的面,光线的反射越强烈。因为需要计算反射光,所以需要有反射面的法向量,一般3D软件导出3D模型时,都可以将法向量一起导出,这里因为我是用一个简单的立方体,方向量也就自己写在程序里了。

首先计算反射点在世界坐标系中的坐标:

// vertex shader
fragPos = vec3(model * vec4(aPos, 1.0));

再计算入射光线方向:

// fragment shader
vec3 lightDir = normalize(lightPos - fragPos);

再计算漫反射颜色:

// fragment shader
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;

这个稍微解释一下,前面提到漫反射的特点是物体的某一部分越是正对着光源就越亮,所以法向量和光线方向的点乘可以用来衡量正对着光源的程度,因为它是正比于夹角的余弦值的。

这里还要注意一个细节,norm不是法线的原本值,而是变换到世界坐标系后的法线方向,因为法线的原本值是它在模型坐标下的,而我们计算光照是在世界坐标系下的,因此需要以下的变换:

// vertex shader
normal = mat3(transpose(inverse(model))) * aNormal;
// fragment shader
vec3 norm = normalize(normal);

这里大家可以发现,法线并不是像顶点那样直接用model矩阵乘就能变换到世界坐标系,因为它是一个方向向量,是没有位置的含义,而model矩阵变换的是位置,因此需要别的方法来变换,推导过程比较复杂,这里不讨论。

下面看镜面光照的计算:

先计算观察方向:

// fragment shader
vec3 viewDir = normalize(viewPos - fragPos);

然后计算高光颜色:

// fragment shader
vec3 reflectDir = reflect(-lightDir, norm);
float spec = myPow(max(dot(viewDir, reflectDir), 0.0), 16);
vec3 specular = specularStrength * spec * lightColor;

这里也稍等解释一下,里面有部分和前面计算漫反射颜色时类似,通过点乘来控制观察方向和反射方向的夹角造成的影响,也就是越是正对着光照反射的方向观察,就能看到越亮的反射光,这里还会给它做幂次方,给它加强一下这种效果。

那么最终的颜色就是三种光的颜色乘以物体颜色:

// fragment shader
vec3 result = (ambient + diffuse + specular) * texColor;
fragColor = vec4(result, 1.0);

来看下最终效果:

接下来我们来搞些复杂一点的,先加个材质,也就是将原来固定的objectColor换成一个texture采样的颜色,很简单,代码就不贴了:

继续搞事情,我们来实现几种光源,光源的种类是什么呢?比如我们现实生活中,有灯泡,灯条,还有带灯罩的灯,这些光源照射到同一个物体上,效果是不一样的。

我们先来看一下平行光,它的特点是来自光源的每条光线就会近似于互相平行,比如太阳光:

很简单,光的方向都是这一个:

// fragment shader
vec3 lightDir = normalize(-lightDirection);

看下效果:

再来看一下点光源,就是类似我们的灯泡,它朝着所有方向发光,光线会随着距离逐渐衰减:

实现方法也很简单,就是衰减系数乘以原来的光照值即可:

// fragment shader
float dist = length(lightPos - fragPos);
float kc = 0.1;
float kl = 0.05;
float kq = 0.05;
float attenuation = 1.0 / (kc + kl * dist + kq * (dist * dist));
ambient  *= attenuation;
diffuse  *= attenuation;
specular *= attenuation;

来看效果,是不是感觉有点像一个灯泡照上去的感觉了?

再来看一种光源,叫聚光,就类似于给一个灯泡加了一个灯罩:

它的计算其实就从点光源改过来的:

// fragment shader
if (theta > 0.8) {
    ...
    float dist = length(lightPos - fragPos);
    float kc = 0.1;
    float kl = 0.1;
    float kq = 0.1;
    float attenuation = 1.0 / (kc + kl * dist + kq * (dist * dist));
    ambient  *= attenuation;
    diffuse  *= attenuation;
    specular *= attenuation;
    vec3 result = (ambient + diffuse + specular) * texColor;
    fragColor = vec4(result, 1.0);
} else {
    fragColor = vec4(ambient * texColor, 1.0);
}

因为要实现灯罩的效果,所以会计算一个夹角,当大于某个值时,就只加环境光,这个值就能控制灯罩的张角,来看下效果:

有灯罩的效果了吧?但是好像看起来不太自然,边缘太生硬了,确实是这样,因为当夹角大于指定值时就只加了环境光,漫反射和高光瞬间就没了,所以边缘比较生硬。

下面优化下,做一个过渡,让漫反射和高光慢慢地消失:

// fragment shader
float epsilon = 0.15;
float intensity = clamp((theta - 0.85) / epsilon, 0.0, 1.0);
diffuse  *= intensity;
specular *= intensity;

看效果,这下边缘柔和多了吧?

下面再讲解一下法向贴图的知识,什么是法向贴图?我们可以看到,上面这个砖头墙面是不是看起来哪里不太自然?是不是太光滑了?法向贴图就是将各点的法向量信息存到一个图上,渲染时会把法向图加载到texture中,会从里面采样出对应点的法向量,将法向图做成法向到处乱指,就能得到凹凸不平的效果。

我们来看看法向图长什么样:

里面的颜色信息其实就是法向量,虽然它也是RGBA,但它的含义其实不是在表达颜色了,只是把向量值存成颜色值而已。

加了法向贴图后,法向量就不是像前面那样一个面都是一个的,是从法向图texture中采样出来的:

// fragment shader
vec3 normal = texture(normalTex, texCoord).rgb;
normal = normalize(normal * 2.0 - 1.0);

这里顺便说一个小知识,如果你之前有接触过法向贴图,可能会发现法向贴图怎么看起来都是偏蓝色的?是因为法向贴图里的法向量一般是用切线空间来表达的,而在切线空间的法向量z分量通常比较大,xyz存成颜色就对应RGB,因此B分量比较大,就看起来偏蓝。

加法向图后的效果:

我做了一个demo:

代码在我的这个仓库:github.com/kenneycode/…

感谢阅读!

参考:

learnopengl-cn.github.io/