9. WebGL光照渲染立方体
# WebGL光照渲染立方体
本节课对WebGL光照的介绍主要目的是让你对WebGL光照,以及如何在代码层面实现WebGL光照算法有个大致的轮廓认知,计算机图形学中关于光照相关算法的介绍更为详细和系统,如果有兴趣可以阅读计算机图形学的书籍。
光线照在物体上,物体反射光线就会构成一个光场,眼睛看到生活中的物体有立体感就是因为有光的存在。因此在学习物理光学在WebGL编程中如何应用, 你就要先了解基本的光学知识。
生活中你看到一个红色立方体,从宏观的角度来看你会描述他是红色的,或者描述RGB值为(1,0,0)的立方体,这时候你要问自己一个问题, 如果呈现到眼睛中的红色平面图像,如果颜色是均匀的,你是否还有立体的感觉。换句话说就是如果把你看到的立方体图像分割成一个个像素单位, 那么每一个像素的RGB值都是(1,0,0)的话你是否有立方体的空间感,从前面课程的实验可以知道,是没有任何空间感的。 那就说明一个问题实际上在自然界中你看到的物体图像,如果看做一个光场,它就是一个变化的光场,并不是每个像素都是(1,0,0)。 这很好理解,抛开专业的物理光学不谈,其实生活中你会发现,比如太阳光照射到一个物体上,不同的面与光的角度不同,反射的到眼睛中的结果不同, 向阳面亮,背光面暗,不同的面不同的颜色,分界位置就有棱角感。
# 光照模型
漫反射

前面提到一个几何体在太阳光下不同的面明暗不同,这主要是因为太阳离地球比较远,太阳光可以视为平行光,几何体不同的面与平行太阳光光线夹角不同, 换句话说就是相同强度的光线照射在表面上角度不同反射光线的强度也就不同,这里就对个现象建立数学模型。
平行光漫反射简单数学模型:漫反射光的颜色 = 几何体表面基色 x 光线颜色 x 光线入射角余弦值
几何体表面基色简单的说就是不考虑光照物体自身的颜色。
# 9. 漫反射模型:光线入射角余弦值
这个数学模型没有考虑镜面反射描述的就是理想的漫反射体,并不能完整的描述物体表面的光场,既然是数学模型,自然就是可以修正的,比如添加一个系数,更改指数等等,这里不再详述。 比如普通的桌子桌面它的粗糙度是微米um级,相比人的脸它是平的,光的波长是纳米nm级,这时候桌子表面相比光线是凹凸不平的,宏观来看一束光线照射到物体的表面,对于理想的漫反射而言,因为表面无规则随机分布凹凸不平的反射面, 光线的反射是不定向的,换句话说任何角度的反射光都是一样的,这也就是说物体反射到眼睛中的光与人的观察位置无关,反射效果展示如上图所示。
关于漫反射光照数学模型中物体的漫反射光强度与光线的入射角有关如何解释,这个其实很简单,比如两块纸板面积相同,如下图所示,两个边长为L的方形板,一个垂直太阳光线放置, 一个不垂直太阳光线也就是入射角不是90度,同样强度的光照条件下,垂直太阳光的纸板的光通量肯定比斜着放的纸板接收的光量大,这时候就有必要给数学模型引入一个入射角的因数。

# RGB分量表示颜色
光的颜色可以使用多种模型来表示,把上面的文字公式使用RGB具体参数来表示形式如下,比如物体表面的颜色是纯红色(1,0,0),入射光是纯白色(1,1,1),光线入射角是60度,余弦值就是0.5,代入下面公式,可以得出结果是(0.5,0,0), 结果仍然是红色,这是符合实际生活的,白色太阳光照在常见的红色物体上,反射的颜色是红色,只是太阳光线照射在物体表面的角度不同,反射的光强度不同。入射光垂直物体表面,也就是入射角是0对应的余弦值是1,光线垂直表面受光量最大, 反射光自然最大,和1对应;入射光线平行物体表面,此时的入射角是90度, 物体表面自然没什么光可以反射。
漫反射数学模型RGB分量表示:(R2,G2,B2) = (R1,G1,B1) x (R0,G0,B0) x cosθ
R2 = R1 * R0 * cosθ
G2 = G1 * G0 * cosθ
B2 = B1 * B0 * cosθ
WebGL着色器语言表示
//角度值转化为弧度值
float radian = radians(60.0);
// reflectedLight的结果是(0.5,0,0)
vec3 reflectedLight = vec3(1.0,0.0,0.0)*vec3(1.0,0.0,0.0)*cos(radian)
# 镜面反射

上面的漫反射数学模型没有考虑镜面反射,描述的就是理想的漫反射体,并不能完整的描述物体表面的光场。漫反射是因为几何体表面粗糙度尺寸相对光波长尺寸而言是凹凸不平的,这种凹凸不平又是随机的,所以说漫反射的光线各个方向是均匀的。 镜面反射也就是说光照在物体上的反射光线具有方向性,具体点说就是光线的反射角等于入射角。生活中的镜子它的表面粗糙度很小,和光的波长是一个数量级,当光线照在上面的时候,反射光线就会表现出方向性。
实际的生活中所有的物体没有绝对的漫反射或者镜面反射,往往都是同时存在,只是表现的倾向性不同,镜子的镜面反射更明显,粗糙的树皮漫反射更明显。光照射到物体上一部分会被吸收,透明的话一部分会被折射, 除去吸收和折射的光剩余的会被反射,反射的时候根据表面的粗糙度不同,镜面反射和漫反射分配的比例不同可以使用两个系数k1、k2去描述。
在室外停放着一辆车,你观察车的时候,你会发现车的外表面会在某个局部出现高光,这很好理解,车的外壳是曲面的,曲面上如果某个区域的的光线反射角刚好是你的视线方向,自然会呈现出局部高亮的现象,其他的部位是漫反射为主。 镜面反射的公式仍然可以写成上面的形式,只是角度不再是光线入射角度而是眼睛视线与反射光线的夹角,n角度余弦值的指数,实际编程的时候你可以自由定义,没有绝对完美的模型,都可以进行修正。
镜面反射光的颜色 = 几何体表面基色 x 光线颜色 x 视线与反射光线的夹角余弦值n
# 环境光照
在暗的环境下,物体比较暗,光亮的环境下,物体比较光亮,描述这个现象可以使用环境光照模型。光线在自然环境中会在不同的物体之间来回反射,单束的光线具有方向性,所有方向的光线随机分布,形成一个没有特殊的光线方向的的环境光照。 多数情况下室内室外环境光颜色通常都是RGB相同的白色到黑色之间的值,(1,1,1)表示最强的环境光照颜色,(0,0,0)相当于处于完全的没有光照的黑色环境中。
环境反射光颜色 = 几何体表面基色 x 环境光颜色
# 复合光照
使用光照渲染模型的时候往往会使用多种光照模型,然后把每个光照模型颜色相乘的结果RGB分别相加,这时候要注意,多种模型的光照颜色相加后RGB的值要保证在区间[0,1],因为WebGL的RGB颜色模型默认RGB分量的最大值是1,注意分配比例即可。
总反射光线 = 漫反射光线 + 镜面反射光线 + 环境反射光线
# 法向量
有基本的数学知识应该都有法线的概念,垂直与面的直线就是面的法线,对于平面而言面上所有位置的法线方向是相同的,对于曲面而言不同的位置法线的方向是变化的。在三维笛卡尔坐标系中,可以使用向量(x,y,z)来表示法向量,根据几何体表面的法向量和光线的方向, 就可以求解出光线入射角的余弦值,法向量的点积计算满足下面的公式,为了方便计算,着色器语言内置了一个方法dot()用来求解两个向量之间的余弦值,已知向量a1(x1,y1,z1)、a2(x2,y2,z2)执行dot(a1,a2)可以求出两个向量a1、a2的余弦值。


# 立方体添加平行光
该WebGL案例源码是通过给一个单色的立方体添加平行光进行渲染,通过这样一个简单的WebGL光照计算案例,来体会光照模型在物体渲染中的应用,在学习下面的代码之前确保你有逐顶点和颜色插值计算的概念,了解顶点位置数据、顶点颜色数据,本节课在这两种顶点数据的基础之上在引入一种新的顶点数据:定点法向量。
平行光照射在立方体上,与不同的平面夹角不同,自然反射的颜色RGB值强弱不同,实际绘图的时候你不可能手动计算去定义每一个像素的值,前面课程中讲解颜色插值计算的知识,应该对你有一定的启发,你只需要计算出每一个顶点在光照下的颜色, 然后利用插值计算就可以得到三个顶点之间区域的像素值,这时候顶点法向量数据就派上了用场,每一个顶点都有位置数据、颜色数据、法向量数据,法向量和颜色数据先进行计算得出新的顶点颜色数据,然后渲染管线对顶点进行装配光栅化的过程中,新的顶点颜色数据进行插值计算。 这时候提示一下大家,不要去从宏观思考法向量问题,要从逐顶点、插值计算的角度理解问题,下面的问题可能大家会有一个疑问,为什么立方体的一个顶点会有三个方向,从实际的物体来看,立方体的一个顶点就是一个顶点,但是从绘图的角度来看, 每一次是通过三个顶点绘制一个三角形面,只不过恰好三个三角形面共顶点,会有顶点位置重复而已,但是每一组的三个顶点是一个装配光栅化和插值计算的独立单元,两组的三个顶点位置有重复也不会影响他们各自的渲染得到的像素结果。
# 顶点着色器:声明变量
attribute vec4 apos;//attribute声明vec4类型变量apos
attribute vec4 a_color;// attribute声明顶点颜色变量
attribute vec4 a_normal;//顶点法向量变量
uniform vec3 u_lightColor;// uniform声明平行光颜色变量
uniform vec3 u_lightPosition;// uniform声明平行光颜色变量
varying vec4 v_color;//varying声明顶点颜色插值后变量
查看上面的代码大家可以发现一个新的关键字uniform,uniform关键字和attribute关键字的都是为了javascript可以向WebGL着色器传递数据而出现。区别在于如果一个变量表示顶点相关的数据并且需要从javascript代码中获取顶点数据,需要使用attribute关键字声明该变量,比如上面代码attribute关键字声明的顶点位置变量apos、顶点颜色变量a_color、顶点法向量变量a_normal。如果一个变量是非顶点相关的数据并且需要javascript传递该变量相关的数据,需要使用uniform关键字声明该变量,比如上面代码通过uniform关键字声明的光源位置变量u_lightPosition、光源颜色变量u_lightColor。更多关于uniform关键字和attribute关键字的内容可以查看第二章着色器介绍2.8 三种变量类型attribute、uniform、varying
在上面代码中涉及WebGL着色器三维向量vec3和四维向量vec4两种数据类型,比如光线的方向需要xyz三个分量描述,可以使用关键字vec3声明,比如顶点位置的齐次坐标(x,y,z,w), 包含透明度的RGB颜色模型RGBA(r,g,b,a)都需要四个分量描述,可以使用关键字vec4声明。
attribute vec4 a_normal;中变量a_normal定义的是vec4类型,第四个参数默认是1.0主要是为了凑成齐次坐标用于矩阵计算,表示法向量方向的是前三个参数,所以执行a_normal.xyz就相当于访问法向量的xyz值,返回的结果是一个vec3数据,如果执行vec3.xy相当于返回一个vec2数据, 如果一个顶点的a_normal数据是(1.0,1.0,1.0,1.0),那么它的模长就不是1,而是3的平方根,这时候需要把前三个1全部除以3的平方根才可以把非单位向量转化为单位向量。
# 顶点着色器:光照计算
// 顶点法向量进行矩阵变换,然后归一化
vec3 normal = normalize((mx*my*a_normal).xyz);
// 计算点光源照射顶点的方向并归一化
vec3 lightDirection = normalize(vec3(gl_Position) - u_lightPosition);
// 计算平行光方向向量和顶点法向量的点积
float dot = max(dot(lightDirection, normal), 0.0);
// 计算反射后的颜色
vec3 reflectedLight = u_lightColor * a_color.rgb * dot;
//颜色插值计算
v_color = vec4(reflectedLight, a_color.a);
dot()是WebGL着色器语言的一个内置函数,可以直接使用,它的参数是两个向量,执行结果是两个向量的点积,如果光线方向向量和顶点法向量两个向量都是单位向量,求解的结果就是平行光线与物体表面法线夹角的余弦值。 内置函数normalize()和dot()一样是WebGL着色器语言提供的用于数学计算的函数,normalize()的作用就是把向量归一化,具体点说就是如果向量的模长不是1,不改变向量方向,把模长变为1,也就是把向量转化为单位向量。
max(dot(lightDirection, normal), 0.0)在dot代码的外面嵌套了一个函数max(),dot()的计算结果作为max()的第一个参数,dot的计算结果可能是[-1,1]之间,颜色不存在负值要舍去[-1,0),这时候就是内置函数max()派上用场的时候,max(dot(lightDirection, normal), 0.0)中max()函数的第二个参数是0, dot()方法的计算结果会和0进行比较运算,返回一个较大的值,着色器语言内置提供了max()函数,自然也有对应的求较小值的函数min()。余弦值是负值的物理意义就是光线无法照的地方,临界点是余弦值0,入射角是90度,也就是说入射光线平行平面, 平行平面相当于光线没有照射到平面上,平面没有收到光自然无法反射光,你可以尝试改变立方体顶点的旋转矩阵可以看到一些面的颜色是黑色,就是因为没有光线照射,这一点是复合实际生活和物理规律的,不管是提本身是什么颜色,没有外界光源,那就表现为黑色。
u_lightColor * a_color.rgb * dot;是套用理想漫反射光照模型的一个公式进行计算,顶点颜色变量a_color是vec4类型包含透明度,计算式中光源颜色变量u_lightColor没有透明度分量A,所以使用a_color.rgb语句返回a_color变量的RGB三个分量,也就是返回一个vec3类型数据。
v_color = vec4(reflectedLight, a_color.a);通过把反射颜色reflectedLight赋值给varying声明的一个变量实现颜色的插值计算,关于插值计算1.7节讲过,不再多谈,这里说一下着色器语言数据类型的相关转化、构造、访问相关问题,vec4、vec3和float、int一样是数据类型的标识关键字,float、int在C语言中都是常见的类型, 着色器语言为了实现大规模的顶点运算增加了vec4、vec3、mat4等数据类型,vec4()、vec3()这时候的表达相当于一个vec4、vec3数据的构造函数,vec4(reflectedLight, a_color.a)中把一个vec3类型数据和一个vec4类型数据的一个分量a作为构造函数vec4的两个参数,来实现创建一个vec4类型数据。访问多元素数据的分量可使用点符号.,从面对象的角度来看, 一个vec4数据是一个对象,数据的一个元素就是数据的一个分量,比如 a_color.a表示vec4类型变量a_color的透明度分量a。关于vec4、vec3等向量数据类型的介绍可以查看第二章WebGL着色器。
# 获取着色器变量
/**
* 从program对象获取相关的变量
* attribute变量声明的方法使用getAttribLocation()方法
* uniform变量声明的方法使用getUniformLocation()方法
**/
var aposLocation = gl.getAttribLocation(program,'apos');
var a_color = gl.getAttribLocation(program,'a_color');
var a_normal = gl.getAttribLocation(program,'a_normal');
var u_lightColor = gl.getUniformLocation(program,'u_lightColor');
var u_lightPosition = gl.getUniformLocation(program,'u_lightPosition');
要想给着色器程序中声明的变量传递数据,首先要获取数据的地址,然后通过指针地址传递给变量。要想获取变量地址,不可能像普通CPU变成一样,要考虑GPU的特殊性,首先要通过调用初始化着色器函数initShader(),把着色器程序处理后通过CPU与GPU的通信传递给GPU配置渲染管线, 执行初始化着色器函数initShader()的同时会返回一个program对象,通过对象program可以获取着色器程序中的变量索引地址,通过WebGL APIgl.getAttribLocation()用来获取attribute关键字声明的顶点变量地址,通过WebGL APIgl.getUniformLocation()获取uniform关键字声明的非顶点变量地址。
# 传递数据
对于简单的数据,不像顶点有大批量数据,不需要在显存上开辟缓冲区上传数据,可以直接使用WebGL APIgl.uniform3f()把数据传递给GPU,uniform3f(变量地址,x,y,z)表示传递三个浮点数x,y,z,接收这个数据的变量是vec3类型,uniform2f(变量地址,x,y)表示传递2个浮点数,接收数据的变量是vec2数据类型, 以此类推还有方法uniform4f()、uniform1f(),命名的特点是数字表示传递的数据有多少分量,f是关键字float的缩写表示浮点数。
/**
* 给平行光传入颜色和方向数据,RGB(1,1,1),单位向量(x,y,z)
**/
gl.uniform3f(u_lightColor, 1.0, 1.0, 1.0);
//保证向量(x,y,-z)的长度为1,即单位向量
// 如果不是单位向量,也可以再来着色器代码中进行归一化
var x = 1/Math.sqrt(14), y = 2/Math.sqrt(14), z = 3/Math.sqrt(14);
gl.uniform3f(u_lightDirection,x,y,-z);
传递uniform变量的WebGL API对应的着色器uniform变量数据类型
| WebGL API | 数据类型 |
|---|---|
| uniform1f() | float |
| uniform2f() | vec2 |
| uniform3f() | vec3 |
| uniform4f() | vec4 |
# 顶点法向量

/**
*顶点法向量数组normalData
**/
var normalData = new Float32Array([
0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,//z轴正方向——面1
1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,//x轴正方向——面2
0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,//y轴正方向——面3
-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,//x轴负方向——面4
0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,//y轴负方向——面5
0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1//z轴负方向——面6
]);
对于立方体而言六个平面也就是有六个不同的平面法向量,但是这里要注意思考如何表达一个面的法向量,你不能说直接告诉GPU一个平面的法向量是(x,y,z),对于立方体而言而言这样比较简单,符合人的思维,但是如果是复杂的曲面这样并不合适,而且顶点着色器是逐顶点处理, 为了表示面的法向量,往往是通过顶点,具体点说是每个顶点对应一个法向量,三个顶点确定一个三角面,三角面的不同位置的法向量相当于通过他的三个顶点的法向量插值得出,或者换个说法就是三个顶点的法向量分别与各自颜色数据进行乘法运算,得到新的顶点颜色, 然后渲染管线利用新的顶点颜色进行插值计算,这就是通过顶点法向量表示面法向量的方法,通过这样的巧妙设计还可以实现法向量的插值计算,只不过插值是通过颜色插值完成的,对于平滑的曲面,过每一个顶点存在一个法平面,也就是有一个法向量,往往在不同三角面中同一位置的顶点法向量是相同的, 对于规则的长方体而言,每个顶点法向量往往在各自平面中有不同的值。
# 顶点法向量矩阵变换
执行代码gl_Position = mx*my*apos;意味着通过旋转矩阵mx和my对立方体顶点数据apos进行旋转变换,也就是说立方体在三维空间中进行了旋转,如果立方体进行了旋转,也就是说立方体表面的法线方向变化了,立方体表面的法线是通过顶点法向量表示的,所以说顶点法向量数据要和顶点位置数据一样执行矩阵变换mx*my*a_normal
//两个旋转矩阵、顶点齐次坐标连乘
gl_Position = mx*my*apos;
// 顶点法向量进行矩阵变换,然后归一化
vec3 normal = normalize((mx*my*a_normal).xyz);
# 视线
通过下面的代码测试,来体会WebGL投影的规律。
课程在《WebGL坐标系》中讲解过WebGL图形系统默认的投影方向,或者说人的眼睛观察几何体的方向,或者说照相机拍照的方向。 代码中的灯光方向的向量数据,第三个参数是负数,也就是说从z轴的角度看,平行光照射物体的方向,就是人眼睛看物体的方向,如果把灯光方向的向量数据z参数更改为正数,刷新浏览器看到的是一个漆黑的立方体投影, 这时候相当于黑暗的环境中,人站在物体的背光面,而不是向光面,对于WebGL图形系统,你可以形象的理解为把光源从屏幕前面放到了屏幕的后面,人的观察方向是沿着z轴负方向,从屏幕外向里观察,光线自然被立方体遮挡住了。
var x = 1/Math.sqrt(15), y = 2/Math.sqrt(15), z = 3/Math.sqrt(15);
gl.uniform3f(u_lightDirection,x,y,-z);
更改-z为z,立方体显示为黑色效果。
gl.uniform3f(u_lightDirection,x,y,z);
# 立方体添加点光源
添加平行光是直接定义光线照射物体的方向,点光源的光线是发散的,无法直接定义它的光线方向,不过只要定义好点光源的位置坐标,然后与某个顶点的位置坐标进行减法运算,计算结果就是光源射到该顶点的方向。 这很好理解,在三维空间中两个点确定一条直线,几何体顶点代表一个点,点光源的位置代表一个点,直线所在的方向就是光线的方向,在三维笛卡尔坐标系中,把两个顶点的xyz三个分量相减就可以得到一个表示直线方向的向量, 把该向量和顶点法向量作为dot()点积函数的参数,可以计算出光线入射角余弦值然后代入漫反射光照模型公式可以得到新的顶点颜色,渲染管线利用新的顶点颜色进行插值计算可以得到立方体表面每一个像素的值。
# 添加点光源位置、颜色数据
WebGL顶点着色器声明两个变量u_lightPosition、u_lightColor分别表示点光源的位置和颜色。
// uniform声明点光源颜色变量
uniform vec3 u_lightColor;
// uniform声明点光源位置变量
uniform vec3 u_lightPosition;
给点光源颜色变量u_lightColor、位置变量u_lightPosition传递数据。
/**
* 传入点光源颜色数据、位置数据
**/
gl.uniform3f(u_lightColor, 1.0, 1.0, 1.0);
gl.uniform3f(u_lightPosition, 2.0, 3.0, 4.0);
# 点光源与顶点方向计算
点光源的光线是发散的,点光源与每一顶点连线的方向都需要单独计算。
vec3(gl_Position) - u_lightPosition用来计算光线方向,然后利用内置函数 normalize()归一化向量数据,vec3(gl_Position)和gl_Position.xyz的写法是等效的,都是为了提取vec4类型顶点数据前三个分量,返回的数据类型是vec3,比如vec2(vec4)就是提取vec4的前两个分量。
// 计算点光源照射顶点的方向并归一化
vec3 lightDirection = normalize(vec3(gl_Position) - u_lightPosition);
// 计算平行光方向向量和顶点法向量的点积
float dot = max(dot(lightDirection, normal), 0.0);