开篇
开头先说说为什么会写这么一篇webgl入门的文章,因为最近的工作投入在三维互动相关的开发,基于webgl引擎库作一些业务层的封装和调用,再输出API给前端使用,算是开始接触webgl这个领域。一开始直接看shader的书和引擎库的代码有些不知所云,后来发现是对webgl缺乏一个整体的了解,对其中的一些概念半知不解。于是看了three.js的文档教程以及webgl编程指南等基础教程,有了全盘的认知之后,再回过头区看代码明显效果更好了。因此,将这个过程中自己认为一些必备的重要入门知识整理了出来,既是一次温故知新,也希望能对很多准备学习webgl的同学带来些许帮助。
webgl是什么
WebGL(全写Web Graphics Library)是一种3D绘图协议,这种绘图技术标准允许把JavaScript和OpenGL ES结合在一起,通过增加OpenGL ES的一个JavaScript绑定(OpenGL是渲染2D、3D矢量图形的一种跨语言、跨平台的应用程序编程接口),WebGL可以为HTML5 Canvas提供硬件3D加速渲染,这样Web开发人员就可以借助系统显卡来在浏览器里更流畅地展示3D场景和模型了。
webgl基础
从一个基本场景开始
如上图是一个最简单的3D场景,由一些必不可少的元素构成。首先会有一个坐标系的概念(webgl默认是右上坐标系,y朝上),作为场景内元素位置的参照。其次是场景中必须有一个摄像机,摄像机就是观察者,用来控制观察者站在场景中的什么位置以什么样的方向去观察场景中实体。实体就是场景中绘制出来的元素,如上图中的立方体,实体有自己的位置、大小、颜色等基本属性。光照是场景中的光源,光照也有颜色和位置等基本属性,影响场景中实体的颜色和亮度。以上这些元素共同构成了一个3D场景,有了这个基本概念后,接下来分别对场景中的这些元素进行介绍。
坐标系和转换
坐标系是用来标识实体位置的参照物,三维坐标系由x、y、z轴组成。至于x、y、z的方向在业内常见的有左手坐标系和右手坐标系两种不同的方向指向,而webgl采用的是右手坐标系。
右手坐标系
顾名思义,右手坐标系可以用右手的手指朝向对照x、y、z轴。如下图所示,右手掌心朝向自己,食指朝上作为y轴正方向,大拇指和中指自然伸展开的朝向就是x轴正方向和z轴正方向,三个手指朝向的反方向即为各坐标轴的反方向。
右手坐标系不仅定义了坐标的方向,同时也定义了旋转的方向。如果要实体要绕x、y、z的某个轴旋转,只需要用大拇指朝向对应坐标轴的正方向,四个手指指向方向即为旋转的正方向。因此在webgl中旋转的正方形是逆时针方向。
有了右手坐标系之后,实体的坐标还跟webgl中其他几个坐标系有关,下面再具体来说明一下:
局部坐标系
局部坐标系指的是物体最初开始的坐标系,不同的物体一开始可能不在同一个局部坐标系之下,所以两者的位置是没有关联的,为了能够建立起关联。需要把两者放到一个统一的坐标系下面,这个统一的坐标系就是世界坐标系。
世界坐标系
世界坐标系指的是物体与WebGL相机建立联系时的坐标系,是一个webgl世界中所有实体放置的统一坐标系。有了这个坐标系之后,实体之间的坐标的比较才有意义。但是实体放入世界坐标系空间之后,虽然与Web相机建立了联系,但是并没有进一步确定观察物体的状态,摄像机从不同的位置和角度观察实体,看到的效果是不同的,这时候就引出了视图坐标系的概念。
视图坐标系
首先用一个简单的例子来说明一些视图坐标系的概念,我们从正面观察一个物体和从侧面观察一个物体看到的物体的形态是不一样的,因为观察者的位置和角度不同。如果保持观察者的位置和角度不变,即从正面看过去,想要达到从侧面观察物体的效果,这时候只能调整物体的位置和姿态来实现。调整后的实体的坐标就是其在视图坐标系下的坐标。所以视图坐标系描述的是模拟相机位置姿态调整下的物体位置。
裁剪坐标系
裁剪坐标系是对视图坐标系的补充和约束,现实中人的眼睛能看到的区域并不是向四周无限延伸的。所以webgl中为了模拟人眼的视觉效果,摄像机的投影引入了可视空间的概念,只有在可视空间范围内的实体才能被绘制出来,可视空间范围外的会被去掉,这就是裁剪坐标系的由来。
屏幕坐标系
最后还有一个屏幕坐标系的概念,在裁剪坐标系下投影平面上的实体要最终展示在屏幕上被我们看到同样有一个坐标转换的过程,转换后显示在屏幕视口上的坐标就是屏幕坐标系中的坐标,这一步WebGL会帮我们自动完成。
坐标系的转换
一个物体最终展示在屏幕中的坐标就是经过了上述在各个坐标系之间的转换得到的,坐标系之间转换的过程就是通过矩阵变化来完成(为什么通过矩阵变化能实现后面单独在矩阵中说明),具体的转换流程如下:
摄像机
摄像机是3D场景中观察者的角色,我们在屏幕上看到的画面实际上是3D空间内的物体映射到摄像机内的画面,摄像机具有位置和姿态两个基本属性,位置和姿态影响着看到的场景中的画面。此外还有可视空间和投影两个重要属性:
投影
投影指的是摄像机照射场景后在屏幕上的成像,在webgl中有两种投影模式,分别是正射投影和透视投影,两种投影下成像的特点有所不同。
正射投影
正射投影表示摄像机的投影是平行发射的,因此场景内的实体不论远近最终映射到屏幕上的大小都是相同的,因此在正射投影的下,场景内实体的大小跟远近无关。正射投影的这一特点常用来绘制大场景如地图、城市模型等。
透视投影
透视投影表示摄像机的投影是从一个点以射线形式在四周发射的,因此场景内距离相机近的实体最终投射到屏幕上会大一些,而距离相机远的实体最终投射到屏幕上会小一些。这跟现实中人眼看到物体近大远小的特点是一致的,因此透视投影下绘制的场景更贴近现实世界的特点。
可视空间
可视空间在前面讲裁剪坐标系的时候有提到,可视空间表示的是场景中能够映射到摄像机内的范围。顺着摄像机投影的方向,会有近裁剪面和远裁剪面两个重要的垂直平面,这两个屏幕和摄像机的视线形成的立体空间就是可视空间。根据投影的不同,正射投影下的可视空间是一个长方体:
而透视投影下的可视空间则是一个四棱锥:
实体
绘制过程
一个实体在场景中被绘制出来需要经过一系列的处理过程,我们就以场景中的一个立方体为例,介绍一个物体在3D场景中被绘制出来的过程:
- 首先会把立方体拆成6个面,先获取平面上预设的的顶点坐标和颜色,存入顶点缓冲区,webgl的顶点着色器(着色器是在GPU上运行的程序)会从顶点缓冲区中读取顶点数据,经过逐顶点的处理,作为下一步图形装配的输入。
- 然后进行图形装配,webgl中的图形最小单元是三角形,任何复杂的图形都是由一个个的小三角形拼接而成的,图形装配会以一定规则连接各个顶点,形成一个个的三角形的图元。
- 紧接着进行图形光栅化的过程,光栅化就是把图元转化为片元(canvas画布上图像的每一个像素都对应一个片元,所以片元可以简单理解为像素,但不是像素)。光栅化后形成的一个个片元数据,作为片元着色器的输入。
- 片元着色器会接收每个顶点的颜色数据,逐片元计算出每一个片元的颜色,并存入颜色缓冲区中。
- 颜色缓冲区中的片元,接下来会经过深度测试(处理两个坐标相同的片元的前后展示)、融合(处理透明度)等处理形成最终的片元数据。
- 最后浏览器会读取颜色缓冲区中的片元数据渲染到屏幕上。
纹理
当实体是表面平滑的简单的物体时,用webgl的着色器内插可以绘制出需要的表面效果。但是如果是表面复杂的物体,用这种方式就很繁琐了。因此webgl提供了纹理来解决这个问题,纹理就是将一张图片贴到几何图形的表面上去,这样图形表面看上去就是这张图片的效果,这张贴图就被成为纹理。
光照
现实中,我们看到物体的颜色实际上是物体反射光线的颜色,根据光源和光线方向的不同,物体不同表面的明暗程度不一致。三维场景中的实体模仿了现实中的特点,因此物体表面的明暗程度由物体本身的材质(决定对光照的漫反射率)和光照(光照的颜色和方向)两者共同决定。
光照类型
场景中的光照可以分为两类,一类是主光源,是让场景中实体可见的主要光照,大体分为点光源和平行光两类。点光源是从一个点向四周发散出的光源,比如现实中的点灯,照射到物体表面不同位置的入射角度是不同的。而平行光是从很远的光源发射出来的与昂,因此光线可以看作是相互平行的,比如太阳光,照射到物体表面不同位置的入射角度是相同的。
另一类是环境光,环境光指的是经主光源发出后被墙壁等其他物体多次反射后照到目标实体上的光,是用来模拟真实世界的辅助光源。为什么要有环境光呢,因为如果只有一个主光源,那么场景中和光照方向平行的平面是完全不能反射光照的,因此表现是漆黑的,这显然于实际不同,所以哪怕是与主光源完全平行的平面也会有暗色,而不是全黑。环境光的存在就是为了补充这一效果,使三维场景更加贴近现实情况。
光照漫反射
光源照射到实体表面的最终被反射的效果与入射角度和实体的平面法向量有关。光照漫反射就是在三维场景中用来模拟因物体表面的粗糙程度不同,反射光的方向也不同的理想模型。
抛开实体本身材质因素的影响,可以认为实体漫反射的颜色是由入射光颜色、实体表面基底色和入射角决定的,可以由公式表示为:
漫反射光颜色 = 入射光颜色 实体表面基底色 cosθ
当入射角为0度,即垂直实体表面入射时,cosθ=1,反射光颜色不会受到削弱;当入射角为90度,即平行实体表面入射时,cosθ=0,反射光度颜色为0,表现为黑色。这与实际情况是一致的。
动画
场景中的简单动画(如旋转)实现的原理就是每隔一定时间修改实体的坐标并重新在场景中绘制,webgl中利用了浏览器提供的 requestAnimationFrame 这个api来实现,和setInterval相比的优势在于:
- requestAnimationFrame采用系统时间间隔,间隔时间相对精确,不会引起丢帧和卡断,绘制的时间紧跟浏览器的刷新频率, 一般是大约每16.7ms(浏览器的刷新频率fps一般在每秒60次左右)执行一次,因此绘制效率更佳。
- requestAnimationFrame是浏览器原生的api,因此如果当前页面没有激活时,渲染方法会自动停止执行。而setInterval的执行与浏览器是否激活无关,是一直运行的。(举个例子:如果打开10个相同的浏览器页面,requestAnimationFrame只会在当前正在浏览的页面中执行,而setInterval在十个页面中都在执行)因此使用requestAnimationFrame的性能更佳。
requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
/**
* 使用方式的伪代码
*/
let then = 0; // 上一帧
let now = 0; // 当前帧
const render = function(timestamp) {
now = timestamp;
const deltaTime = now - then; // 与上一帧的时间差值
then = now;
currentAngle = animate(currentAngle, datalTime); // 更新旋转角度
draw(gl, n, currentAngle, modelMatrix, u_ModelMatrix); // 重新绘制实体
requestAnimationFrame(render); // 在浏览器下一帧重复执行render函数
};
render();
requestAnimationFrame使用中有2个需要注意的点:
- requestAnimationFrame要放在要执行的回调函数中才能达到重复调用的目的
- requestAnimationFrame会给回调函数传入一个参数(timestamp),表示每次调用之间的时间间隔,通过这个间隔算出与上一帧的时间差值,进一步就可以调整绘制的逻辑保证渲染的平滑。(如:每次旋转的角度根据datalTime进行修正,这样可以保证旋转的速度是不变的,否则在高帧率浏览器中旋转会越来越快)
空间几何基础
向量
向量,指具有大小和方向的量。它可以形象化地表示为带箭头的线段。箭头代表向量的方向,线段长度代表向量的大小。下面介绍一下在webgl中最常用到的点乘和叉乘以及应用场景
点乘
几何意义
向量a(x1,y1,z1),向量b(x2,y2,z2),则向量a点乘向量b为:a·b = |a||b|·cosθ
我们考虑当向量a和b都是单位向量的时候,a·b = cosθ
而cosθ又可以表示为单位向量a在单位向量b方向上的投影: cosθ = 投影长度 / 1 = 投影长度
即 a.b = a向量在b向量方向上的投影长度,θ值越小,a向量就越贴合b向量。所以点乘在webgl中的意义是判断两个向量的接近程度。
应用场景
如下图所示:已知一条行进路线,路线由一个个点连接起来,要在这条行进路线上的两侧选取对应的点,这时候就可以利用向量点乘,判断前进路线方向的向量和与与两侧点构成的向量之间的点乘大小进行筛选。
叉乘
几何意义
叉乘,是一种在向量空间中向量的二元运算,它的运算结果是一个向量,并且这个向量与原来两个向量所在的平面垂直。因为叉乘后得到的是一个与原来两向量垂直的向量,方向遵循右手法则,所以叉乘在webgl中的几何意义就是求所在平面的法向量。
应用场景
最常见的应用就是求已知平面的法向量,用一个具体的应用例子来说明一下:
为了求p3点的坐标,可以先求出向量p2p1和向量vertVec3所在平面的法向量croVec3,再通过向量croVec3和向量p2p1之间的运算关系得出最终p3点的坐标。伪代码如下所示:
import { vec3 } from 'gl-matrix';
private p1: vec3 = vec3.create(); // 已知坐标点_p1
private p2: vec3 = vec3.create(); // 已知坐标点_p2
private p3: vec3 = vec3.create(); // 待求坐标点_p3
const normVec3 = vec3.create();
vec3.subtract(normVec3, this.p1, this.p2); // 得到向量p2p1
vec3.normalize(normVec3, normVec3) // 归一化
const vertVec3 = [0, 1, 0] as vec3; // 垂直向量p2p1所在平面的向量
const croVec3 = vec3.create();
vec3.cross(croVec3, vertVec3, normVec3); // 叉乘得到垂直向量 方向遵循右手法则
vec3.normalize(croVec3, croVec3); //归一化
vec3.scale(normVec3, normVec3, 0.5); //缩放成0.5单位长度的向量(0.5m)
vec3.scale(croVec3, croVec3, 1) // 缩放成单位长度的向量(1m)
const tempVec3 = vec3.create();
vec3.add(tempVec3, normVec3, croVec3); //相加得到向量p2p3
vec3.add(this.p3, tempVec3, this.p2); // 换算得到p3点坐标
矩阵
矩阵是一个按照长方阵列排列的复数或实数集合,由 m × n 个数aij排成的m行n列的数表称为m行n列的矩阵,简称m × n矩阵。记作:
矩阵在webgl中有非常广泛的运用,因为坐标的变化都可以通过变幻矩阵来表示,在前面介绍坐标系转换的时候,各个视图之间的转换都是通过模型矩阵来完成的。实体从A位置变幻到B位置,是经过一系列的旋转平移得到的,坐标的变幻都可以用矩阵的乘法表示为:
等式左侧的这个矩阵就是变幻矩阵,通过推导(具体的推导过程可以自行查阅)可以得出平移矩阵为:
旋转矩阵为:
缩放矩阵为:
插值
插值是离散函数逼近的重要方法,利用它可通过函数在有限个点处的取值状况,估算出函数在其他点处的近似值。插值在图像渲染中也被用来填充图像变换时像素之间的空隙。
插值的求法可以有多种插值函数来求,比如最邻近元法、线性插值法等,这些具体等算法在工程中不强制要掌握,会有常用的数学库封装好插值函数。在webgl中遇到求连续变化过程中的实时值时,插值就有了用武之地了,下面通过一个具体的例子来具体说明下插值的应用场景:
应用场景
假设一个小人以匀速从A点走到B点,已知A点和B点的坐标,要计算行走过程中的实时坐标。
要求A到B过程中到实时坐标变化,只需要对起点A和终点B的坐标以总时间为插值系数进行插值即可得出小人每一帧的坐标,具体的伪代码如下所示:(其中插值的两行关键代码在update函数中)
import { vec3 } from 'gl-matrix';
private p_start: vec3 | undefined; // 起点坐标
private p_end: vec3 | undefined; // 终点坐标
private p_move: vec3 = vec3.create(); //移动过程中的实时坐标
private speed: number // 移动的速度
private time: number // 移动需要的时间
private currentTime = 0 // 当前时间
private ratio = 0 // 移动的距离和总距离的比例
// 只会在进入时执行一次的钩子函数
onEnter() {
const distance = vec3.distance(this.p_start, this.p_end);
this.time = distance / this.speed;
const dir = vec3.create();
vec3.subtract(dir, this.p_end, this.p_end);
dir[1] = 0; // 地面行走,处理y坐标为0
vec3.normalize(dir, dir); //归一化
this.srcInfo.playAnimation('walk'); // 行走动画
this.faceToDir(dir, [0, 1, 0]); // 自定义函数,朝向行走方向
}
// 在浏览器每一帧都会执行的钩子函数
update(deltaTime: number) {
if (!this.p_start || !this.p_end) {
return;
}
this.currentTime += deltaTime;
this.ratio = this.currentTime / this.time;
if (this.currentTime >= this.time) {
this.ratio = 1;
}
vec3.lerp(this.p_move, this.p_start, this.p_end, this.ratio); // 插值求当前移动的坐标
this.setPosition(this.p_move); // 自定义函数,改变当前人物坐标
if (this.ratio === 1) {
this.reset();
// 到达终点,可以做自定义的业务逻辑
......
}
}
// 只会在退出时执行一次的钩子函数
onExit () {
this.reset();
}
reset(){
this.currentTime = 0;
this.ratio = 0;
}
尾声
通过上面对webgl中的一些基础知识和相关的空间几何中的常用基础知识的简单介绍,对webgl的入门有了初步的窥探。后面有时间会逐步对webgl中的一些核心模块作进一步的介绍和分析,在webgl方向上的不断深入。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。