[WebGL入门]二十一,从平行光源发出的光

注:文章译自http://wgld.org/,原作者杉本雅広(doxas),文章中如果有我的额外说明,我会加上[lufy:],另外,鄙人webgl研究还不够深入,一些专业词语,如果翻译有误,欢迎大家指正。

照亮世界

上次绘制了一个像甜甜圈一样的圆环体模型,虽然没有涉及特别的新知识,但是也算成功的绘制出了3D模型了吧。
那么,这次来看一下光。
光在3D渲染中有很多种类和使用方法,想把光研究透彻,也是很不容易的。
现实世界中我们能看到物体,是因为物体反射的光进入我们的眼睛。也就是说,没有光的话,我们的眼睛是看不到任何东西的。在3D的编程世界里,就算没有光,也可以对模型进行渲染。目前为止,并没有使用过光照处理,也一样绘制了多边形。但是,如果在模拟的世界中加入了光,那么3D的视觉效果会得到巨大的飞跃。
这次介绍的光,是从一般的平行光源(定向灯)发出的光,是实现起来比较简单的一种光。在详细介绍平行光源之前,先来简单说一下光。

光的模拟

平行光源发出的光,就要处理光的遮挡,也就是说要处理影子的效果。这个看一下这次的demo的运行结果,和上篇文章中的demo对比一下就明白了。

处理光的时候,和光碰撞的部分颜色应该是明的,而没有和光碰撞的部分颜色应该是暗的。如果没有光的话,所有的颜色的亮度都应该是一样的。模拟光的时候,在没有光的一侧,应该添加影子。
WebGL中,颜色的强度的范围在0 ~ 1 之间。根据RGBA的各个要素中,设定的值的不同来决定。处理光的时候,在原来的RGBA的值上乘与相应的系数,这个系数的范围也是0 ~ 1 之间,有光的一面,显示为原色相近的状态,而背光的一面则使用较暗的颜色。
比如说,RGBA的各个元素为0.5的话,光系数为0.5,这样相乘的话,得到RGBA的各元素为 0.5 x 0.5 = 0.25,这样就比原来的颜色暗了。按照这个原理,分别计算相应的光的强度和颜色的强度,然后相乘,最终就能处理光和影了。

什么是平行光源

平行光源,是从无限远的地方发出,并使得发出的光在整个三维空间中始终保持平行的光源。这个概念听起来比较难理解。
主要是说,光的方向保持一致,相对于三维空间中的任何一个模型来说,光照的方向都是一样的,如下图

黄色的箭头表示光的方向。
平行光源发出的光计算的负担并不算大,实现起来比较简单,所以在3D编程中经常使用。而且,和平行光源发出的光的碰撞,需要知道光的方向,可以用向量来定义,然后传给着色器,就可以实现了。
但是,实际上,只有光的方向是实现不了光照的效果的,还需要顶点的法线情报,那么法线是什么呢,下面来详细介绍。

法线向量和光向量

对3D编程和数学不太了解的人,基本上没怎么听说过法线这个词。简单来说,法线就是一个带有方向的向量,在二维空间中为了表示某线相垂直的方向,在三维空间中为了表示某个面的方向,要使用法线向量。
但是,为什么实现光照效果的时候,除了光的方向还需要光的法线呢?
现实世界中,从太阳或者灯发出来的光,照到物体上之后会发生反射,所以考虑到反射,从光源发出的光,碰到物体表面之后,发生反射,光的方向发生了改变。

上图中的粉色的线表示光的轨道,和面向光的面相撞后,方向就会发生改变。这样,形成模型的面的方向就能左右光的轨道。
3D中的光,只是在一定程度上进行光的模拟演算,没有必要完全模拟现实世界中的光的轨道和运动。因为完全模拟的话,那计算量就太大了。这次的平行光源发出的光,以顶点的法线向量和光的方向(光向量)为基础,一定程度上计算光的扩散,反射等。
光垂直射向一个面的话,这个面会将光完全反射,也就是说,对光的影响很大。反之,一个面没有光的话,光也就完全不会扩散,如下图。

光向量和法线向量之间的夹角超过90度的话,就对光没有影响力了。这个计算,用向量之间的内积可以得到。内积这里就不详细解释了,想详细了解的人可以自己查一下相关资料。
内积可以通过着色器内置的函数轻松的进行计算,这个不需要担心。只要准备好正确的数据,剩下的计算交给WebGL就行了。
所以,这一回必须要修改顶点着色器,当然javascript部分也需要进行修改,来慢慢看吧。

定向灯的着色器

那么,先来看着色器的部分吧。这次对着色器的修改只是针对顶点着色器,在顶点着色器中进行光的计算,然后将计算结果传给片段着色器。
>顶点着色器的代码
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform   mat4 mvpMatrix;
uniform   mat4 invMatrix;
uniform   vec3 lightDirection;
varying   vec4 vColor;

void main(void){
    vec3  invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
    float diffuse  = clamp(dot(normal, invLight), 0.1, 1.0);
    vColor         = color * vec4(vec3(diffuse), 1.0);
    gl_Position    = mvpMatrix * vec4(position, 1.0);
}
现在的demo和之前有很大不同,乍一看貌似挺复杂,来看一下具体变更点。
首先,从变量开始。
着色器的attribute变量中,新添加了normal,这个变量用来储存顶点的法线信息。而uniform函数增加了两个。一个是用来接收模型坐标变换矩阵的逆矩阵的变量invMatrix,另一个是用来接收光的方向,也就是从平行光源发出的光的方向的向量的变量lightDirection。
什么是逆矩阵呢?
这次在顶点着色器中添加的invMatrix是用来保存模型坐标变换矩阵的逆矩阵的变量,估计大多数人都不知道什么叫做逆矩阵吧。
平行光源发出的光(定向灯发出的光)通常需要光向量,三维空间中的所有的模型都被同一方向的光照射。但是,试想一下,通过模型坐标变换,可以对模型的放大缩小,旋转,移动,如果只通过法线和光向量进行计算的话,会受到光的方向,位置,模型的方向,位置等的影响。
本来正确的光的位置和方向,因为受到模型坐标变换的影响,就得不到正确的结果了。因此,通过对模型的坐标变换进行完全的逆变换,来抵消模型坐标变换的影响。
模型沿着x轴旋转45度的话,就向反方向旋转45度,这样就抵消了旋转,模型即使发生了旋转,光源位置和光的方向也可以固定。同样,对模型进行缩放的话,是矩阵相乘运算,可以通过和逆矩阵相乘来抵消。
这样,就需要为光准备一个模型坐标变换矩阵的逆矩阵,在minMatrix.js中提供了生成逆矩阵的函数,本网站使用它来进行光的计算。
接着,光照的时候,还需要计算光系数,这部分代码如下。
>光照系数的计算
vec3  invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
float diffuse  = clamp(dot(normal, invLight), 0.1, 1.0);
vColor         = color * vec4(vec3(diffuse), 1.0);
首先,最开始声明一个vec3类型的变量invLight,而且进行了一些计算。
最开始的normalize是一个内置函数,作用是将向量标准化。使用这个函数,将模型坐标变换的逆矩阵和光向量相乘的结果进行标准化。模型进行了旋转等坐标变换的话,也可以通过逆变换来抵消。这个计算的后面还有个.xyz,这个是为了把变换结果作为正确的三维向量来代入。
接着是float类型的变量diffuse的值的获取。其实这里是求法线和光向量的内积,这里出现的clamp和dot都是GLSL的内置函数,clamp是将值限制在一定的范围内,第二个参数是最小值,第三个参数是最大值。之所以要限制范围,是因为向量的内积可能出现负数值,为了防止这种情况而进行的处理。
另一个内置函数是dot是用来求内积的,参数一个是法线,另一个是经过逆矩阵处理后的光向量。
最后,将算出的光系数,和顶点颜色相乘,将结果传给varying变量。片段着色器中,通过接收到的这个参数,来决定最终的颜色。

向VBO中追加法线信息

这次修改的地方比较多,javascript也来看一下吧。
上一篇中,生成圆环体的顶点数据的函数稍微做了一些修改,修改的内容是,将法线的信息也一起返回。上一篇为止,只返回了位置,颜色,索引这三个,法线情报也需要返回。
法线就是上面说的那样,是一个表示方向的向量,和位置情报一样,用 X Y Z 这三个元素来表示。另外,法线标准化之后的范围在0 ~ 1之间。
>生成圆环体和法线信息的添加
// 生成圆环体的函数
function torus(row, column, irad, orad){
    var pos = new Array(), nor = new Array(),
        col = new Array(), idx = new Array();
    for(var i = 0; i <= row; i++){
        var r = Math.PI * 2 / row * i;
        var rr = Math.cos(r);
        var ry = Math.sin(r);
        for(var ii = 0; ii <= column; ii++){
            var tr = Math.PI * 2 / column * ii;
            var tx = (rr * irad + orad) * Math.cos(tr);
            var ty = ry * irad;
            var tz = (rr * irad + orad) * Math.sin(tr);
            var rx = rr * Math.cos(tr);
            var rz = rr * Math.sin(tr);
            pos.push(tx, ty, tz);
            nor.push(rx, ry, rz);
            var tc = hsva(360 / column * ii, 1, 1, 1);
            col.push(tc[0], tc[1], tc[2], tc[3]);
        }
    }
    for(i = 0; i < row; i++){
        for(ii = 0; ii < column; ii++){
            r = (column + 1) * i + ii;
            idx.push(r, r + column + 1, r + 1);
            idx.push(r + column + 1, r + column + 2, r + 1);
        }
    }
    return [pos, nor, col, idx];
}
从生成圆环体的函数,返回了相应的法线信息。需要注意的是,生成圆环体的函数中,返回的数组中元素的顺序是[ 位置信息 ]?[ 法线信息 ]?[ 顶点颜色 ]?[ 索引 ]。
函数中都做了什么,可能一眼看不出来,但是想要做的处理和前面一样,就是将法线情报标准化,圆环体的顶点坐标的输出部分和法线信息的输出部分都分别做了处理
接着,来看一下在生成圆环体的函数被调用的部分。
>关于顶点数据的处理
// 获取attributeLocation并放入数组
var attLocation = new Array();
attLocation[0] = gl.getAttribLocation(prg, ‘position‘);
attLocation[1] = gl.getAttribLocation(prg, ‘normal‘);
attLocation[2] = gl.getAttribLocation(prg, ‘color‘);

// 将attribute的元素个数保存到数组中
var attStride = new Array();
attStride[0] = 3;
attStride[1] = 3;
attStride[2] = 4;

// 生成圆环体的顶点数据
var torusData = torus(32, 32, 1.0, 2.0);
var position = torusData[0];
var normal = torusData[1];
var color = torusData[2];
var index = torusData[3];

// 生成VBO
var pos_vbo = create_vbo(position);
var nor_vbo = create_vbo(normal);
var col_vbo = create_vbo(color);
和以前的demo不同,为了处理法线,增加了一个数组normal,并且利用这个数组生成了一个VBO。而为了在顶点着色器中接收法线信息,声明了一个attribute类型的变量,所以不要忘了获取attributeLocation。
另外,还增加了一个uniform类型的变量,所以也需要追加一个获取uniformLocation的处理。
>uniform相关的处理
// 获取uniformLocation并保存到数组中
var uniLocation = new Array();
uniLocation[0] = gl.getUniformLocation(prg, ‘mvpMatrix‘);
uniLocation[1] = gl.getUniformLocation(prg, ‘invMatrix‘);
uniLocation[2] = gl.getUniformLocation(prg, ‘lightDirection‘);
最开始可能不容易把握,总值着色器和脚本是不可分割的,双方必须成对出现在代码中。

添加关于光的处理

那么最后,看一下将与光相关的参数传入着色器的处理,首先是代码。
>定义光和矩阵相关的数据
// 各矩阵的生成和初始化
var mMatrix = m.identity(m.create());
var vMatrix = m.identity(m.create());
var pMatrix = m.identity(m.create());
var tmpMatrix = m.identity(m.create());
var mvpMatrix = m.identity(m.create());
var invMatrix = m.identity(m.create());

// 视图x投影坐标变换矩阵
m.lookAt([0.0, 0.0, 20.0], [0, 0, 0], [0, 1, 0], vMatrix);
m.perspective(45, c.width / c.height, 0.1, 100, pMatrix);
m.multiply(pMatrix, vMatrix, tmpMatrix);

// 平行光源的方向
var lightDirection = [-0.5, 0.5, 0.5];
定义了一个含有三个元素的向量lightDirection,这一次定义的光,是从左后方向原点前进的光。另外,矩阵的初始化部分,增加了新的invMatrix,这个invMatrix中的数据如下。
>逆矩阵的定义和生成
// 计数器自增
count++;

// 用计数器计算角度
var rad = (count % 360) * Math.PI / 180;

// 模型坐标变换矩阵的生成
m.identity(mMatrix);
m.rotate(mMatrix, rad, [0, 1, 1], mMatrix);
m.multiply(tmpMatrix, mMatrix, mvpMatrix);

// 根据模型坐标变换矩阵生成逆矩阵
m.inverse(mMatrix, invMatrix);

// uniform变量
gl.uniformMatrix4fv(uniLocation[0], false, mvpMatrix);
gl.uniformMatrix4fv(uniLocation[1], false, invMatrix);
gl.uniform3fv(uniLocation[2], lightDirection);
利用minMatrix.js内置的inverse函数来计算模型坐标变换矩阵的逆矩阵,并指定正确的uniformLocation,同时设置光的方向lightDirection。
这次,光的方向是不变的,所以没有必要每次循环都设置,但是为了容易理解,所以放到了一起处理。注意,因为光向量是一个包含有三个元素的向量,所以和矩阵不同,使用的是uniform3fv,参数的个数也不一样。

总结

写的太长了,果然,就算是简单点说,关于光的处理也需要很长的描述。
重点是,3D渲染中没有办法完全模拟现实中的光,只是大致是那么回事而已。
完全模拟自然界的物理学的话,计算量是非常大的,所以代替这些的就是这次所介绍的,使用平行光源,法线,逆矩阵等技术,在一定程度上尽可能的让画面看起来真实。
理解这次文章的内容,需要一定程度的数学知识,向量,法线,矩阵,这些在平常生活中是不会出现的,但是好好考虑一下的话,应该是可以理解的。
demo的连接会在文章的最后给出,这次修改的内容比较多,所以一次贴出所有的代码。
lufy:代码太长,我就不贴了,大家直接打开demo用浏览器自己看吧。

下次,进一步深入介绍关于光的内容。

通过平行光源来绘制圆环体的demo



郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。