3D in web

OpenGL介绍

  • OpenGL 是一种跨平台的图形 API,其自诞生至今已催生了各种计算机平台及设备上的数千优秀应用程序。
  • 目前OpenGL已经停止更新了,同一公司下的Vulkan引擎在将来可能会大放异彩。但是这并不妨碍我们学习WebGL与OpenGL。

WebGL简要介绍

  • WebGL是一种3D绘图协议。
  • 在Linux/Unix和MacOS上,WebGL是基于OpenGL的,而在Windows系统上,则是基于微软的DirectX。
  • 这种绘图技术标准允许把JavaScript和OpenGL ES 2.0结合在一起,WebGL可以为HTML5 Canvas提供硬件3D加速渲染,
  • 这样Web开发人员就可以借助系统显卡来在浏览器里更流畅地展示3D场景和模型。

  • 目前WebGL技术在各个方面都有很多应用,Google的街景地图,3D的可视化网页上的3D模型等等,像Three.js、AFrame这种3d框架也都是基于WebGL的。

##Echarts的3D可视化例子: 3d可视化

WebGL怎么写?

  • 着色器(Shader)
  • 程序(Program)
  • 数据(Buffer)

着色器的介绍

  • 着色器程序,是运行在 GPU 中负责渲染算法的一类总称。语言是类似C语法的GLSL。因为写的代码是执行在 CPU 中的,因此,现在我们可以自豪地说,我们在写 GPU 程序...

  • 事实上,着色器通常是用来做一些渲染效果上的事,比如水面的渲染、马赛克效果、素描风格化渲染等等……

着色器有两种:

  1. 顶点着色器
  2. 片段(片元)着色器

  3. 在顶点着色器中,可以访问到顶点的三维位置、颜色、法向量等信息。可以通过修改这些值,或者将其传递到片元着色器中,实现特定的渲染效果。

  4. 在片元着色器中,可以访问到片元在二维屏幕上的坐标、深度信息、颜色等信息。通过改变这些值,可以实现特定的渲染效果。

e.g.

attribute vec2 a_position;
uniform vec2 u_resolution;
void main()
{
    vec2 zeroToOne = a_position / u_resolution;
    vec2 zeroToTwo = zeroToOne * 2.0;
    vec2 clipSpace = zeroToTwo - 1.0;
    gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}
precision mediump float;
void main ()
{
    gl_FragColor = vec4(1, 0, 0.5, 1);
}

写在哪里?

  1. 拼接字符串
  2. Type不为javascript的script标签中
  3. ES6中的多行字符串将着色器代码写到js中

[slide style="text-align: left;"]

程序的介绍

Program是着色器的容器,我们写好着色器代码后,需要用Program将其引入到js中,并且链接,交给WebGL。

  • 写一个C程序的步骤:

    1. 创建一个文件(.c)
    2. 写源代码
    3. 编译为obj文件(.o)
    4. 链接,成为可执行文件
  • 怎么写着色器程序

    1. 创建一个着色器对象
    2. 将源码放进着色器中
    3. 编译着色器
    4. 添加到程序中,并链接。
var vs,fs,vs_s,fs_s;
//创建顶点着色器和片段着色器
vs=webgl.createShader(webgl.VERTEX_SHADER);
fs=webgl.createShader(webgl.FRAGMENT_SHADER);
//着色器程序的源码
vs_s="attribute vec4 p;void main(){gl_Position=p;}";
fs_s="void main(){gl_FragColor=vec4(1.0,0.0,0.0,1.0);}";
//把源码添加进着色器
webgl.shaderSource(vs,vs_s);
webgl.shaderSource(fs,fs_s);
//编译着色器
webgl.compileShader(vs);
webgl.compileShader(fs);
//把着色器添加到程序中
webgl.attachShader(program,vs);
webgl.attachShader(program,fs);
//把这两个着色器程序链接成一个完整的程序
webgl.linkProgram(program);

buffer的介绍

createPositionBuffer = function () {
    let b = this.gl.createBuffer();
    this.gl.bindBuffer(this.gl.ARRAY_BUFFER, b);
    this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(this.positions), this.gl.STATIC_DRAW);
    this.positionBuffer = b;
}

Buffer的主要作用就是存储图形所需要的数据,并且最后会传到着色器程序中。

图元的介绍

简单来讲,图元就是组成图像的基本单元,其实在WebGL中基本图元只有三大类,它们分别是,点、线和三角形。无论需要画什么样的图形都只能用这些图元去拼凑。

三角形是最常用的图元,WebGL所有的图形都是由三角形拼成的。

简单的几何学

投影

  • 在WebGL和Three.js中,都是运用的右手坐标系,对于WebGL,X方向和Y方向的坐标范围都是[-1,1],人眼观察的方向是Z轴的负方向。
  • 投影分为正交投影和透视投影。
  • 正交投影可以理解为上帝视角,人眼在Z轴的方向内观察物体没有任何偏移,我们看到的空间是一个正方体空间,最前面和最后面的东西的大小由坐标来决定。
  • 这样是真实的,但是却不符合人眼所看到的。

横看成岭侧成峰 远景高低各不同

因为透视,所以我们才会在相同的物体上看到不同的景色。 怎么在我们所绘制的内容中使用透视呢? 我们先从一张图说起: perspective

假设我们人眼在EYE这个地方,往Z轴负方向看,离眼睛近的那条边的y的长度是[-1,1],而离眼睛远的那条边的长度也是[-1,1],这显然在透视模型中是不可能的。 所以我们要做一些变化,让我们的y根据z的不同来改变,所以这里我们假设有一个方法f(z)能够做到这一点,所以:$$f(z) = y$$,怎么来计算呢,假设我们知道FOV(机器所观察到的角度),在上图中,$$y/-z = tan(fov/2)$$;x同理$$x/-z = tan(fov/2)$$; 我们令$$f = 1/tan(fov/2)$$,故有$$y = 1/f * -z -> y = -z/f$$;

有了以上知识,我们来构建我们的矩阵来将正交投影转为透视视图,这里有四个参数分别得到fov,屏幕比例,最近能够看到的点,最远能够看到的点。

function perspective(fov,aspect,near,far){
    var f = Math.tan(Math.PI/2 - fov/2);
    var rangeInv = 1.0 / (near - far); 

    return [
    //  x         y      z                           w 
        f/aspect, 0,     0,                          0,
        0,        f,     0,                          0,
        0,        0,     (near + far)*rangeInv,  -1,
        0,        0,     near*far* rangeInv *2,   0
    ];
}

变换

  1. 平移
| ?  ?  ? |       | x |     | x+t |
| ?  ?  ? |   *   | y |  =  | y+t |
| ?  ?  ? |       | z |     | z+t |

这个矩阵怎么实现?

在三维矩阵运算中,我们很难去定义这样一个矩阵满足如上的内容。 所以我们通过增加一维内容在实现平移的转换:

| 1  0  0  t |       | x |      | x+t |
| 0  1  0  t |       | y |      | y+t | 
| 0  0  1  t |   *   | z |   =  | z+t |
| 0  0  0  1 |       | 1 |      |  1  |

在shader中,gl_Position 的值为四维就是因为这个原因。可以通过四维矩阵乘法来保证齐次性变换

  1. 旋转

在webGL中,旋转操作对于某个物体来说是作用于这个物体上的每个顶点相对于原点沿着某条特定的线的旋转距离。 特殊情况下为绕X,Y,Z轴,在下面的情况中我们固定z轴,得到: webgl rotate

故对于坐标点(x0,y0,z0)和目标坐标点(xr,yr,zr),如果转动了Θ角度:

$$ cos(\alpha+\theta) = cos\alpha cos\theta - sin\alpha sin\theta $$

| cosΘ  -sinΘ  0  0 |       | x |      | cosΘx0 - sinΘy0 |
| sinΘ   cosΘ  0  0 |       | y |      | sinΘx0 + cosΘy0 | 
|    0      0  1  0 |   *   | z |   =  |  z  |
|    0      0  0  1 |       | 1 |      |  1  |
  1. 缩放
| w  0  0 |       | x |     | w * x |
| 0  w  0 |   *   | y |  =  | w * y |
| 0  0  w |       | z |     | w * z |

但是为了统一,我们还是用四维的矩阵

| w  0  0  0 |       | x |      | x * w |
| 0  w  0  0 |       | y |      | y * w | 
| 0  0  w  0 |   *   | z |   =  | z * w |
| 0  0  0  1 |       | 1 |      |   1   |

无论是投影还是这里的变换,都用到了矩阵的变换

在WebGL中的变换方式有两种,一种是javascript阶段,一种是shader阶段,由于shader阶段是在GPU中运算,而且GLSL对于矩阵有很好的优化,所以我们选择在shader中进行运算。

小结

因为 WebGL的各种API都是过程式的,用起来各种不方便,也很容易忘记。所以还是自己封装一下,更方便使用。

其实不仅仅是前端,图形渲染对于整个软件工程来说,都是一个很特定的研究领域。这就意味着,大部分情况下,你可能并没有那么迫切的需求去学习它。这也是为什么,WebGL 标准推出了那么多年,在前端的各种分享会上,即使介绍,也永远都是 Hello World。

Three.js简介

一个程序: 渲染器(Renderer) 场景(Scene) 照相机(Camera)

Render

渲染器是将canvas元素和程序绑定的桥梁。一个THREE的程序,从渲染器开始,也从渲染器结束。

renderer = new THREE.WebGLRenderer(
    document.getElementById('canvas')
);

...

renderer.render(scene, camera);

Scene

场景相当于一个比较大的容器,Three.js中的物体都是添加到场景中的,场景不会有很复杂的操作。 场景通常也会很早实例化。

    var scene = new THREE.Scene();

camera

照相机。什么是照相机?

现实中的照相机:咔嚓

程序中的照相机:3维到2维的抽象

camera定义了三维空间到二维空间的投影方式。我们刚才已经谈到过投影有正交投影和透视投影之分,照相机也分这两种。

THREE.OrthgrapicCamera(left, right, top, bottom, near, far); //正交投影
THREE.PerspectiveCamera(fov, aspect, near, far) //透视投影

而在webgl中,正交投影和透视投影,我们都得自己构造矩阵来进行变换,但是在Three.js中我们只需要传入特定的参数就可以。

camera有一个lookAt方法,lookAt函数接受的是一个THREE.Vector3的实例。使得相机朝指定的方向观察,关于这个函数的实现,后面和大家稍微分析一下。

物体

之前在WebGL,我们讲着色器分顶点着色器和片段着色器。着色器的叫法可能抽象了一点。在Three.js中,物体传入两个参数,一个是形状,一个是材质。

形状

在WebGL中,我们构造3D图形,需要传入图元的顶点,需要考虑用哪种图元保存的数组才是最小的。

在Three.js中,封装了绝大多数的形状,我们写Three.js就像写Canvas一样。

THREE.SphereGeometry(radius, segmentsWidth, segmentsHeight, phiStart, phiLength, thetaStart, thetaLength) 

比如构造一个球体,我们只需要调用上面的构造函数就可以。radius是半径;segmentsWidth表示经度上的切片数;segmentsHeight表示纬度上的切片数;phiStart表示经度开始的弧度;phiLength表示经度跨过的弧度;thetaStart表示纬度开始的弧度;thetaLength表示纬度跨过的弧度。

因为构造球体是一个近似的过程,切片数目越多 越近似于球。

材质

材质(Material)是独立于物体顶点信息之外的与渲染效果相关的属性。通过设置材质可以改变物体的颜色、纹理贴图、光照模式等。

Three.js中的材质分为基本材质、 Lambert材质、 Phong材质等。几种材质主要区别是对于光照模型的处理算法上的不同,Phong材质对于金属、镜面等镜面反射占主要的材质比较适合,Lambert则相反。基本材质则不受光照影响。

继续回到形状,

Three.js帮我们封装好了一些物体,Mesh(网格)是最有代表性的,由点 线 面 构成的形状,都属于网格,此外还有粒子系统等。

Mesh(geometry, material) 

我们直接把形状和材质传入网格的构造函数,再加入Scene中,就可以生成想要的物体了。

mesh.position.set(1.5, -0.5, 0); 

实例上也有一些属性可以设置。

动画

HTML5提供了requestAnimationFrame方法

为什么不用setInterval?

光源

  1. 环境光
  2. 平行光
  3. 点光源
  4. 聚光灯

引入外部模型

很多复杂的模型,直接用Three建立也是很有难度的,这时候通常会用3dMax或者Maya导出模型文件,用Ajax等请求的方式导入进Three.js。

调试

chrome之前的版本中可以开启WebGL的分析,但是新版本中并没有看到,后续可能会完善功能后推出。

chrome的插件 WebGL inspector。可以分析出着色器,某一帧buffer的内容,贴图等。

Three.js 部分代码分析

lookAt 的矩阵生成

THREE.Matrix4 = function () {
    this._x = new THREE.Vector3();
    this._y = new THREE.Vector3();
    this._z = new THREE.Vector3();
};
THREE.Matrix4.prototype = {
    n11: 1, n12: 0, n13: 0, n14: 0,
    n21: 0, n22: 1, n23: 0, n24: 0,
    n31: 0, n32: 0, n33: 1, n34: 0,
    n41: 0, n42: 0, n43: 0, n44: 1,
    ...
    lookAt: function ( eye, center, up ) {
        var x = this._x, y = this._y, z = this._z;
        z.sub( eye, center );
        z.normalize();
        x.cross( up, z );
        x.normalize();
        y.cross( z, x );
        y.normalize();
        this.n11 = x.x; this.n12 = x.y; this.n13 = x.z; this.n14 = - x.dot( eye );
        this.n21 = y.x; this.n22 = y.y; this.n23 = y.z; this.n24 = - y.dot( eye );
        this.n31 = z.x; this.n32 = z.y; this.n33 = z.z; this.n34 = - z.dot( eye );
    },
}

简单的数学知识:矩阵的叉乘(cross)

lookAt:

| x.x  x.y  x.z  eye.x |       | a |      | ? |
| x.x  x.y  x.z  eye.x |       | b |      | ? | 
| x.x  x.y  x.z  eye.x |   *   | c |   =  | ? |
| 0    0    0    1     |       | 1 |      | ? |

总结

用途:

  1. 酷炫的活动页(AFrame、Hilo的3d版本)
  2. 数据可视化 (Echarts的3d图表)
  3. 游戏、直播视频的处理 。。。

参考: