金沙棋牌官方平台

当前位置:金沙棋牌 > 金沙棋牌官方平台 > 透视投影,教你用webgl快速创建一个小世界

透视投影,教你用webgl快速创建一个小世界

来源:http://www.logblo.com 作者:金沙棋牌 时间:2019-11-29 12:44

webgl世界 matrix入门

2017/01/18 · HTML5 · matrix, WebGL

原文出处: AlloyTeam   

这次没有带来游戏啦,本来还是打算用一个小游戏来介绍阴影,但是发现阴影这块想完完整整介绍一次太大了,涉及到很多,再加上业务时间的紧张,所以就暂时放弃了游戏,先好好介绍一遍webgl中的Matrix。

这篇文章算是webgl的基础知识,因为如果想不走马观花的说阴影的话,需要打牢一定的基础,文章中我尽力把知识点讲的更易懂。内容偏向刚上手webgl的同学,至少知道着色器是什么,webgl中drawElements这样的API会使用~

文章的标题是Matrix is magic,矩阵对于3D世界来说确实是魔法一般的存在,说到webgl中的矩阵,PMatrix/VMatrix/MMatrix这三个大家相信不会陌生,那就正文let’s go~

教你用webgl快速创建一个小世界

2017/03/25 · HTML5 · AlloyTeam

原文出处: AlloyTeam   

Webgl的魅力在于可以创造一个自己的3D世界,但相比较canvas2D来说,除了物体的移动旋转变换完全依赖矩阵增加了复杂度,就连生成一个物体都变得很复杂。

什么?!为什么不用Threejs?Threejs等库确实可以很大程度的提高开发效率,而且各方面封装的非常棒,但是不推荐初学者直接依赖Threejs,最好是把webgl各方面都学会,再去拥抱Three等相关库。

上篇矩阵入门中介绍了矩阵的基本知识,让大家了解到了基本的仿射变换矩阵,可以对物体进行移动旋转等变化,而这篇文章将教大家快速生成一个物体,并且结合变换矩阵在物体在你的世界里动起来。

注:本文适合稍微有点webgl基础的人同学,至少知道shader,知道如何画一个物体在webgl画布中

透视投影,与Z BUFFER求值
   
    为什么有透视。因为眼球是个透镜。假如地球生物进化的都靠超声波探测空间,那也许眼睛就不会有变成球,而是其他形状...
为什么有人玩3D头晕?其中一个重要的作用是,眼球不完全是个透镜。所以当视野大于60度,屏幕四周投影的变形就比眼球投影视网膜利害多。而且人脑习惯了矫正眼球投影的信息。突然有个屏幕上粗糙的模拟眼球成像,大脑还真一时适应不了。

坐标系统(Coordinate System)

OpenGL希望在所有顶点着色器运行后,所有我们可见的顶点都变为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的x,y,z坐标都应该在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。

我们通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标转换为标准化设备坐标。然后将这些标准化设备坐标传入光栅器(Rasterizer),再将他们转换为屏幕上的二维坐标或像素。

将坐标转换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步,也就是类似于流水线那样子,实现的,在流水线里面我们在将对象转换到屏幕空间之前会先将其转换到多个坐标系统。

将对象的坐标转换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中进行一些操作或运算更加方便和容易。

对我们来说比较重要的总共有5个不同的坐标系统:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)、

这些就是我们将所有顶点转换为片段之前,顶点需要处于的不同的状态。
接下来我们将会通过展示完整的图片来解释每一个坐标系实际做了什么。

1/ 矩阵的来源

刚刚有说到PMatrix/VMatrix/MMatrix这三个词,他们中的Matrix就是矩阵的意思,矩阵是干什么的?用来改变顶点位置信息的,先牢记这句话,然后我们先从canvas2D入手相信一下我们有一个100*100的canvas画布,然后画一个矩形

XHTML

<canvas width="100" height="100"></canvas> ctx.rect(40, 40, 20, 20); ctx.fill();

1
2
3
<canvas width="100" height="100"></canvas>
ctx.rect(40, 40, 20, 20);
ctx.fill();

代码很简单,在画布中间画了一个矩形

现在我们希望将圆向左移动10px

JavaScript

ctx.rect(30, 40, 20, 20); ctx.fill();

1
2
ctx.rect(30, 40, 20, 20);
ctx.fill();

金沙棋牌官方平台,结果如下:

源码地址:
结果展示:金沙棋牌官方平台 1

 

改变rect方法第一个参数就可以了,很简单,因为rect()对应的就是一个矩形,是一个对象,canvas2D是对象级别的画布操作,而webgl不是,webgl是片元级别的操作,我们操作的是顶点
用webgl如何画一个矩形?地址如下,可以直接查看源码

源码地址:
结果展示:

金沙棋牌官方平台 2

这里我们可以看到position这个数组,这里面存的就是矩形4个点的顶点信息,我们可以通过操作改变其中点的值来改变位置(页面源码也可以看到实现),但是扪心自问这样不累吗?有没有可以一次性改变某个物体所有顶点的方式呢?
有,那就是矩阵,magic is coming

1  0  0  0
0  1  0  0
0  0  1  0
0  0  0  1

上面这个是一个单位矩阵(矩阵最基础的知识这里就不说了),我们用这个乘一个顶点(2,1,0)来看看
金沙棋牌官方平台 3

并没有什么变化啊!那我们换一个矩阵来看

1  0  0  1
0  1  0  0
0  0  1  0
0  0  0  1

再乘之前那个顶点,发现顶点的x已经变化了!
金沙棋牌官方平台 4

如果你再多用几个顶点试一下就会发现,无论我们用哪个顶点,都会得到这样的一个x坐标+1这样一个结果
来,回忆一下我们之前的目的,现在是不是有了一种一次性改变顶点位置的方式呢?

 

2/ 矩阵规律介绍
刚刚我们改变了矩阵16个值中的一个,就使得矩阵有改变顶点的能力,我们能否总结一下矩阵各个值的规律呢?当然是可以的,如下图

金沙棋牌官方平台 5
这里红色的x,y,z分别对应三个方向上的偏移

金沙棋牌官方平台 6
这里蓝色的x,y,z分别对应三个方向上的缩放

然后是经典的围绕各个轴的旋转矩阵(记忆的时候注意围绕y轴旋转时,几个三角函数的符号……)
金沙棋牌官方平台 7

还有剪切(skew)效果的变换矩阵,这里用个x轴的例子来体现
金沙棋牌官方平台 8

这里都是某一种单一效果的变化矩阵,可以相乘配合使用的,很简单。我们这里重点来找一下规律,似乎所有的操作都是围绕着红框这一块来的
金沙棋牌官方平台 9
其实也比较好理解,因为矩阵这里每一行对应了个坐标
金沙棋牌官方平台 10

那么问题来了,最下面那行干啥用的?
一个顶点,坐标(x,y,z),这个是在笛卡尔坐标系中的表示,在3D世界中我们会将其转换为齐次坐标系,也就是变成了(x,y,z,w),这样的形式(之前那么多图中w=1)
矩阵的最后一行也就代表着齐次坐标,那么齐次坐标有啥作用?很多书上都会说齐次坐标可以区分一个坐标是点还是向量,点的话齐次项是1,向量的话齐次项是0(所以之前图中w=1)
对于webgl中的Matrix来说齐次项有什么用处呢?或者说这个第四行改变了有什么好处呢?一句话概括(敲黑板,划重点)
它可以让物体有透视的效果
举个例子,大名鼎鼎的透视矩阵,如图
金沙棋牌官方平台 11
在第四行的第三列就有值,而不像之前的是0;还有一个细节就是第四行的第四列是0,而不是之前的1

写到这里的时候我纠结了,要不要详细的把正视和透视投影矩阵推导写一下,但是考虑到篇幅,实在是不好放在这里了,否则这篇文章要太长了,因为后面还有内容
大部分3D程序开发者可能不是很关注透视矩阵(PMatrix),只是知道有这一回事,用上这个矩阵可以近大远小,然后代码上也就是glMatrix.setPerspective(……)一下就行了
所以决定后面单独再写一篇,专门说下正视透视矩阵的推导、矩阵的优化这些知识
这里就暂且打住,我们先只考虑红框部分的矩阵所带来的变化
金沙棋牌官方平台 12

为什么说webgl生成物体麻烦

我们先稍微对比下基本图形的创建代码
矩形:
canvas2D

JavaScript

ctx1.rect(50, 50, 100, 100); ctx1.fill();

1
2
ctx1.rect(50, 50, 100, 100);
ctx1.fill();

webgl(shader和webgl环境代码忽略)

JavaScript

var aPo = [     -0.5, -0.5, 0,     0.5, -0.5, 0,     0.5, 0.5, 0,     -0.5, 0.5, 0 ];   var aIndex = [0, 1, 2, 0, 2, 3];   webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW); webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);   webgl.vertexAttrib3f(aColor, 0, 0, 0);   webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);   webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var aPo = [
    -0.5, -0.5, 0,
    0.5, -0.5, 0,
    0.5, 0.5, 0,
    -0.5, 0.5, 0
];
 
var aIndex = [0, 1, 2, 0, 2, 3];
 
webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW);
webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);
 
webgl.vertexAttrib3f(aColor, 0, 0, 0);
 
webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);
 
webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0);

完整代码地址:
结果:
金沙棋牌官方平台 13

圆:
canvas2D

JavaScript

ctx1.arc(100, 100, 50, 0, Math.PI * 2, false); ctx1.fill();

1
2
ctx1.arc(100, 100, 50, 0, Math.PI * 2, false);
ctx1.fill();

webgl

JavaScript

var angle; var x, y; var aPo = [0, 0, 0]; var aIndex = []; var s = 1; for(var i = 1; i <= 36; i++) {     angle = Math.PI * 2 * (i / 36);     x = Math.cos(angle) * 0.5;     y = Math.sin(angle) * 0.5;       aPo.push(x, y, 0);       aIndex.push(0, s, s+1);       s++; }   aIndex[aIndex.length - 1] = 1; // hack一下   webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW); webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);   webgl.vertexAttrib3f(aColor, 0, 0, 0);   webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);   webgl.drawElements(webgl.TRIANGLES, aIndex.length, webgl.UNSIGNED_SHORT, 0);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var angle;
var x, y;
var aPo = [0, 0, 0];
var aIndex = [];
var s = 1;
for(var i = 1; i <= 36; i++) {
    angle = Math.PI * 2 * (i / 36);
    x = Math.cos(angle) * 0.5;
    y = Math.sin(angle) * 0.5;
 
    aPo.push(x, y, 0);
 
    aIndex.push(0, s, s+1);
 
    s++;
}
 
aIndex[aIndex.length - 1] = 1; // hack一下
 
webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW);
webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);
 
webgl.vertexAttrib3f(aColor, 0, 0, 0);
 
webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);
 
webgl.drawElements(webgl.TRIANGLES, aIndex.length, webgl.UNSIGNED_SHORT, 0);

完整代码地址:
结果:
金沙棋牌官方平台 14

总结:我们抛开shader中的代码和webgl初始化环境的代码,发现webgl比canvas2D就是麻烦很多啊。光是两种基本图形就多了这么多行代码,抓其根本多的原因就是因为我们需要顶点信息。简单如矩形我们可以直接写出它的顶点,但是复杂一点的圆,我们还得用数学方式去生成,明显阻碍了人类文明的进步。
相比较数学方式生成,如果我们能直接获得顶点信息那应该是最好的,有没有快捷的方式获取顶点信息呢?
有,使用建模软件生成obj文件。

Obj文件简单来说就是包含一个3D模型信息的文件,这里信息包含:顶点、纹理、法线以及该3D模型中纹理所使用的贴图
下面这个是一个obj文件的地址:

    Z BUFFER数值计算,以及PERSPECTIVE PROJECTION MATRIX设置,使用D3D或者OPENGL,可以直接让显卡完成这些工作。但是弄清Z BUFFER如何计算,以及PROJECT MATRIX的原理。对于进行各种高级图像处理,非常有用。例如shadowmap的应用。目前为了得到好的shadowmap,很多人在如何加大shadowmap精度做了很多努力(改变生成shadowmap时的perspective project matrix来生成精度更合理的shadowmap) 。比如透视空间的perspective shadow map,light空间的Light-space perspective shadow,perspective shadowmap变种Trapezoidal shadow maps,改正交投影为对数参数投影的 Logarithmic Shadow Maps。另外,Doom3中shadow volume采用的无限远平面透视矩阵绘制stencil shadow volume。都需要对perspective projection有透彻了解。

整体概述

为了将坐标从一个坐标系转换到另一个坐标系,我们需要用到几个转换矩阵,最重要的几个分别是模型(Model)视图(View)投影(Projection)三个矩阵。首先,顶点坐标开始于局部空间(Local Space),称为局部坐标(Local Coordinate),然后经过世界坐标(World Coordinate)观察坐标(View Coordinate)裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)结束。

下面的图示显示了整个流程及各个转换过程做了什么:

金沙棋牌官方平台 15

  1. 局部坐标是对象相对于局部原点的坐标;也是对象开始的坐标。
  2. 将局部坐标转换为世界坐标,世界坐标是作为一个更大空间范围的坐标系统。这些坐标是相对于世界的原点的。
  3. 接下来我们将世界坐标转换为观察坐标,观察坐标是指以摄像机或观察者的角度观察的坐标。
  4. 在将坐标处理到观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标是处理-1.0到1.0范围内并判断哪些顶点将会出现在屏幕上。
  5. 最后,我们需要将裁剪坐标转换为屏幕坐标,我们将这一过程成为视口变换(Viewport Transform)。视口变换将位于-1.0到1.0范围的坐标转换到由glViewport
    函数所定义的坐标范围内。最后转换的坐标将会送到光栅器,由光栅器将其转化为片段。

我们之所以将顶点转换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。例如,当修改对象时,如果在局部空间中则是有意义的;当对对象做相对于其它对象的位置的操作时,在世界坐标系中则是有意义的;等等这些。如果我们愿意,本可以定义一个直接从局部空间到裁剪空间的转换矩阵,但那样会失去灵活性。接下来我们将要更仔细地讨论各个坐标系。

3/ webgl的坐标系

我们前面bb了那么多,可以总结一下就是“矩阵是用来改变顶点坐标位置的!”,可以这么理解对吧(不理解的再回去看下第二节里面的各种图)

那再看下文章开头说的PMatrix/VMatrix/MMatrix三个,这三个货都是矩阵啊,都是来改变顶点位置坐标的,再加上矩阵也是可以结合的啊,为什么这三个货要分开呢?

首先,这三个货分开说是为了方便理解,因为它们各司其职

MMatrix --->  模型矩阵(用于物体在世界中变化)
VMatrix --->  视图矩阵(用于世界中摄像机的变化)
PMatrix --->  透视矩阵

模型矩阵和视图矩阵具体的原理和关系我之前那篇射击小游戏文章里有说过,它们的改变的一般就是仿射变换,也就是平移、旋转之类的变化
这里稍微回忆一下原理,具体细节就不再说了
这两货一个是先旋转,后平移(MMatrix),另一个是先平移,后旋转(VMatrix)
但就这个小区别,让人感觉一个是物体本身在变化,一个是摄像机在变化

好啦,重点说下PMatrix。这里不是来推导出它如何有透视效果的,这里是讲它除了透视的另一大隐藏的功能
说到这里,先打一个断点,然后我们思考另一个问题

canvas2D中和webgl中画布的区别

它们在DOM中的宽高都是通过设置canvas标签上width和height属性来设置的,这很一致。但webgl中我们的坐标空间是-1 ~ 1

金沙棋牌官方平台 16
(width=800,height=600中canvas2D中,矩形左顶点居中时,rect方法的前两个参数)

金沙棋牌官方平台 17
(width=800,height=600中webgl中,矩形左顶点居中时,左顶点的坐标)

我们会发现x坐标小于-1或者大于1的的话就不会展示了(y同理),x和y很好理解,因为屏幕是2D的,画布是2D的,2D就只有x,y,也就是我们直观上所看到的东西
那z坐标靠什么来看到呢?

对比

首先至少有两个物体,它们的z坐标不同,这个z坐标会决定它们在屏幕上显示的位置(或者说覆盖)的情景,让我们试试看

JavaScript

var aPo = [ -0.2, -0.2, -0.5, 0.2, -0.2, -0.5, 0.2, 0.2, -0.5, -0.2, 0.2, -0.5 ]; var aIndex = [0, 1, 2, 0, 2, 3]; webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW); webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0); webgl.vertexAttrib3f(aColor, 1, 0, 0); webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW); // 先画一个z轴是-0.5的矩形,颜色是红色 webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0); aPo = [ 0, -0.4, -0.8, 0.4, -0.4, -0.8, 0.4, 0, -0.8, 0, 0, -0.8 ]; webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer()); webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW); webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0); webgl.vertexAttrib3f(aColor, 0, 1, 0); // 再画一个z轴是-0.8的矩形,颜色是绿色 webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var aPo = [
    -0.2, -0.2, -0.5,
    0.2, -0.2, -0.5,
    0.2, 0.2, -0.5,
    -0.2, 0.2, -0.5
];
var aIndex = [0, 1, 2, 0, 2, 3];
webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW);
webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);
webgl.vertexAttrib3f(aColor, 1, 0, 0);
webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(aIndex), webgl.STATIC_DRAW);
// 先画一个z轴是-0.5的矩形,颜色是红色
webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0);
aPo = [
    0, -0.4, -0.8,
    0.4, -0.4, -0.8,
    0.4, 0, -0.8,
    0, 0, -0.8
];
webgl.bindBuffer(webgl.ARRAY_BUFFER, webgl.createBuffer());
webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(aPo), webgl.STATIC_DRAW);
webgl.vertexAttribPointer(aPosition, 3, webgl.FLOAT, false, 0, 0);
webgl.vertexAttrib3f(aColor, 0, 1, 0);
// 再画一个z轴是-0.8的矩形,颜色是绿色
webgl.drawElements(webgl.TRIANGLES, 6, webgl.UNSIGNED_SHORT, 0);

注意开启深度测试,否则就没戏啦
(不开启深度测试,计算机会无视顶点的z坐标信息,只关注drawElements(drawArrays)方法的调用顺序,最后画的一定是最上一层)

代码中A矩形(红色)的z值为-0.5, B矩形(绿色)的z值为-0.8,最终画布上谁会覆盖谁呢?
如果我问的是x=0.5和x=0.8之间,谁在左,谁在右,我相信每个人都确定知道,因为这太熟了,屏幕就是2D的,画布坐标x轴就是右大左小就是这样的嘛

那我们更深层的考虑下为什么x和y的位置没人怀疑,因为“左手坐标系”和“右手坐标系”中x,y轴是一样的,如图所示

金沙棋牌官方平台 18

而左手坐标系和右手坐标系中的z轴正方向不同,一个是屏幕向内,一个是屏幕向外,所以可以认为
如果左手坐标系下,B矩形(z=-0.8)小于A矩形(z=-0.5),那么理应覆盖了A矩形,右手坐标系的话恰恰相反

事实胜于雄辩,我们所以运行一下代码

查看结果:

可以看到B矩形是覆盖了A矩形的,也就意味着webgl是左手坐标系

excuse me???所有文章说webgl都是右手坐标系啊,为什么这里居然是左手坐标系?

答案就是webgl中所说的右手坐标系,其实是一种规范,是希望开发者一起遵循的规范,但是webgl本身,是不在乎物体是左手坐标系还是右手坐标系的

可事实在眼前,webgl左手坐标系的证据大家也看到了,这是为什么?刚刚说的有点笼统,不应该是“webgl是左手坐标系”,而应该说“webgl的裁剪空间是按照左手坐标系来的”

裁剪空间词如其名,就是用来把超过坐标空间的东西切割掉(-1 ~ 1),其中裁剪空间的z坐标就是按照左手坐标系来的

代码中我们有操作这个裁剪空间吗?有!回到断点的位置!

就是PMatrix它除了达成透视效果的另一个能力!
其实无论是PMatrix(透视投影矩阵)还是OMatrix(正视投影矩阵),它们都会操作裁剪空间,其中有一步就是将左手坐标系给转换为右手坐标系

怎么转化的,来,我们用这个单位矩阵试一下

1  0  0  0
0  1  0  0
0  0  -1  0
0  0  0  1

只需要我们将z轴反转,就可以得到将裁剪空间由左手坐标系转变为右手坐标系了。用之前的矩形A和矩形B再试一次看看

地址:

果然如此!

这样我们就了解到了webgl世界中几个最为关键的Matrix了

简单分析一下这个obj文件

金沙棋牌官方平台 19
前两行看到#符号就知道这个是注释了,该obj文件是用blender导出的。Blender是一款很好用的建模软件,最主要的它是免费的!

金沙棋牌官方平台 20
Mtllib(material library)指的是该obj文件所使用的材质库文件(.mtl)
单纯的obj生成的模型是白模的,它只含有纹理坐标的信息,但没有贴图,有纹理坐标也没用

金沙棋牌官方平台 21
V 顶点vertex
Vt 贴图坐标点
Vn 顶点法线

金沙棋牌官方平台 22
Usemtl 使用材质库文件中具体哪一个材质

金沙棋牌官方平台 23
F是面,后面分别对应 顶点索引 / 纹理坐标索引 / 法线索引

这里大部分也都是我们非常常用的属性了,还有一些其他的,这里就不多说,可以google搜一下,很多介绍很详细的文章。
如果有了obj文件,那我们的工作也就是将obj文件导入,然后读取内容并且按行解析就可以了。
先放出最后的结果,一个模拟银河系的3D文字效果。
在线地址查看:

在这里顺便说一下,2D文字是可以通过分析获得3D文字模型数据的,将文字写到canvas上之后读取像素,获取路径。我们这里没有采用该方法,因为虽然这样理论上任何2D文字都能转3D,还能做出类似input输入文字,3D展示的效果。但是本文是教大家快速搭建一个小世界,所以我们还是采用blender去建模。

以下描述z buffer计算以及perspective projection matrix原理。

局部空间(Local Space)

局部空间是指对象所在的坐标空间。有可能你创建的所有模型都以(0,0,0)为初始位置,然而他们会在世界的不同位置。则你的模型的所有顶点都是在局部空间:他们相对于你的对象来说都是局部的。

4/ 结语

至于具体的PMatrix和OMatrix是怎么来的,Matrix能否进行一些优化,我们下次再说~

有疑问和建议的欢迎留言一起讨论~

1 赞 1 收藏 评论

金沙棋牌官方平台 24

具体实现

假设坐标在 world space 是
Pw = {Xw,Yw,Zw}

世界空间(World Space)

世界空间中的坐标就如它们听起来那样:是指顶点相对于(游戏)世界的坐标。物体变换到的最终空间就是世界坐标系,并且你会想让这些物体分散开来摆放(从而显得更真实)。对象的坐标将会从局部坐标转换到世界坐标;该转换是由模型矩阵(Model Matrix)实现的。
模型矩阵是一种转换矩阵,它能通过对对象进行平移、缩放、旋转来将它置于它本应该在的位置或方向。

1、首先建模生成obj文件

这里我们使用blender生成文字
金沙棋牌官方平台 25

经过camera space transform 得到
Pe = {Xe,Ye,Ze}

观察空间(View Space)

观察空间经常被人们称之OpenGL的摄像机(Camera)(所以有时也称为摄像机空间(Camera Space)或视觉空间(Eye Space))。
观察空间就是将对象的世界空间的坐标转换为观察者视野前面的坐标。因此观察空间就是从摄像机的角度观察到的空间。而这通常是由一系列的平移和旋转的组合来平移和旋转场景从而使得特定的对象被转换到摄像机前面。
这些组合在一起的转换通常存储在一个观察矩阵(View Matrix)里,用来将世界坐标转换到观察空间。在下一个教程我们将广泛讨论如何创建一个这样的观察矩阵来模拟一个摄像机。

2、读取分析obj文件

JavaScript

var regex = { // 这里正则只去匹配了我们obj文件中用到数据     vertex_pattern: /^vs+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)/, // 顶点     normal_pattern: /^vns+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)/, // 法线     uv_pattern: /^vts+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)/, // 纹理坐标     face_vertex_uv_normal: /^fs+(-?d+)/(-?d+)/(-?d+)s+(-?d+)/(-?d+)/(-?d+)s+(-?d+)/(-?d+)/(-?d+)(?:s+(-?d+)/(-?d+)/(-?d+))?/, // 面信息     material_library_pattern: /^mtllibs+([d|w|.]+)/, // 依赖哪一个mtl文件     material_use_pattern: /^usemtls+([S]+)/ };   function loadFile(src, cb) {     var xhr = new XMLHttpRequest();       xhr.open('get', src, false);       xhr.onreadystatechange = function() {         if(xhr.readyState === 4) {               cb(xhr.responseText);         }     };       xhr.send(); }   function handleLine(str) {     var result = [];     result = str.split('n');       for(var i = 0; i < result.length; i++) {         if(/^#/.test(result[i]) || !result[i]) { // 注释部分过滤掉             result.splice(i, 1);               i--;         }     }       return result; }   function handleWord(str, obj) {     var firstChar = str.charAt(0);     var secondChar;     var result;       if(firstChar === 'v') {           secondChar = str.charAt(1);           if(secondChar === ' ' && (result = regex.vertex_pattern.exec(str)) !== null) {             obj.position.push(+result[1], +result[2], +result[3]); // 加入到3D对象顶点数组         } else if(secondChar === 'n' && (result = regex.normal_pattern.exec(str)) !== null) {             obj.normalArr.push(+result[1], +result[2], +result[3]); // 加入到3D对象法线数组         } else if(secondChar === 't' && (result = regex.uv_pattern.exec(str)) !== null) {             obj.uvArr.push(+result[1], +result[2]); // 加入到3D对象纹理坐标数组         }       } else if(firstChar === 'f') {         if((result = regex.face_vertex_uv_normal.exec(str)) !== null) {             obj.addFace(result); // 将顶点、发现、纹理坐标数组变成面         }     } else if((result = regex.material_library_pattern.exec(str)) !== null) {         obj.loadMtl(result[1]); // 加载mtl文件     } else if((result = regex.material_use_pattern.exec(str)) !== null) {         obj.loadImg(result[1]); // 加载图片     } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
var regex = { // 这里正则只去匹配了我们obj文件中用到数据
    vertex_pattern: /^vs+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)/, // 顶点
    normal_pattern: /^vns+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)/, // 法线
    uv_pattern: /^vts+([d|.|+|-|e|E]+)s+([d|.|+|-|e|E]+)/, // 纹理坐标
    face_vertex_uv_normal: /^fs+(-?d+)/(-?d+)/(-?d+)s+(-?d+)/(-?d+)/(-?d+)s+(-?d+)/(-?d+)/(-?d+)(?:s+(-?d+)/(-?d+)/(-?d+))?/, // 面信息
    material_library_pattern: /^mtllibs+([d|w|.]+)/, // 依赖哪一个mtl文件
    material_use_pattern: /^usemtls+([S]+)/
};
 
function loadFile(src, cb) {
    var xhr = new XMLHttpRequest();
 
    xhr.open('get', src, false);
 
    xhr.onreadystatechange = function() {
        if(xhr.readyState === 4) {
 
            cb(xhr.responseText);
        }
    };
 
    xhr.send();
}
 
function handleLine(str) {
    var result = [];
    result = str.split('n');
 
    for(var i = 0; i < result.length; i++) {
        if(/^#/.test(result[i]) || !result[i]) { // 注释部分过滤掉
            result.splice(i, 1);
 
            i--;
        }
    }
 
    return result;
}
 
function handleWord(str, obj) {
    var firstChar = str.charAt(0);
    var secondChar;
    var result;
 
    if(firstChar === 'v') {
 
        secondChar = str.charAt(1);
 
        if(secondChar === ' ' && (result = regex.vertex_pattern.exec(str)) !== null) {
            obj.position.push(+result[1], +result[2], +result[3]); // 加入到3D对象顶点数组
        } else if(secondChar === 'n' && (result = regex.normal_pattern.exec(str)) !== null) {
            obj.normalArr.push(+result[1], +result[2], +result[3]); // 加入到3D对象法线数组
        } else if(secondChar === 't' && (result = regex.uv_pattern.exec(str)) !== null) {
            obj.uvArr.push(+result[1], +result[2]); // 加入到3D对象纹理坐标数组
        }
 
    } else if(firstChar === 'f') {
        if((result = regex.face_vertex_uv_normal.exec(str)) !== null) {
            obj.addFace(result); // 将顶点、发现、纹理坐标数组变成面
        }
    } else if((result = regex.material_library_pattern.exec(str)) !== null) {
        obj.loadMtl(result[1]); // 加载mtl文件
    } else if((result = regex.material_use_pattern.exec(str)) !== null) {
        obj.loadImg(result[1]); // 加载图片
    }
}

代码核心的地方都进行了注释,注意这里的正则只去匹配我们obj文件中含有的字段,其他信息没有去匹配,如果有对obj文件所有可能含有的信息完成匹配的同学可以去看下Threejs中objLoad部分源码

然后经过project transform 转为 device space,这里假设转为 Zp 范围 [-1,1](OPENG的Z BUFFER)

裁剪空间(Clip Space)

在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个给定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就被忽略了,所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间名字的由来。

因为将所有可见的坐标都放置在-1.0到1.0的范围内不是很直观,所以我们会指定自己的坐标集(Coordinate Set)并将它转换回标准化设备坐标系,就像OpenGL期望它做的那样。

为了将顶点坐标从观察空间转换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了坐标的范围,例如,每个维度都是从-1000到1000。投影矩阵接着会将在它指定的范围内的坐标转换到标准化设备坐标系中(-1.0,1.0)。所有在范围外的坐标在-1.0到1.0之间都不会被绘制出来并且会被裁剪。在投影矩阵所指定的范围内,坐标(1250,500,750)将是不可见的,这是由于它的x坐标超出了范围,随后被转化为在标准化设备坐标中坐标值大于1.0的值并且被裁剪掉。

如果只是片段的一部分例如三角形,超出了裁剪体积(Clipping Volume),则OpenGL会重新构建三角形以使一个或多个三角形能适应在裁剪范围内。(???)

由投影矩阵创建的观察区域(Viewing Box)被称为平截头体(Frustum),且每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将一定范围内的坐标转化到标准化设备坐标系的过程(标准化坐标系能很容易被映射到2D观察空间坐标)被称之为投影(Projection),因为使用投影矩阵能将3维坐标投影(Project)到很容易映射到2D的标准化设备坐标系中。

一旦所有顶点被转换到裁剪空间,最终的操作——透视划分(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视划分是将4维裁剪空间坐标转换为3维标准化设备坐标。这一步会在每一个顶点着色器运行的最后被自动执行。

在这一阶段之后,坐标经过转换的结果将会被映射到屏幕空间(由glViewport
设置)且被转换成片段。

投影矩阵将观察坐标转换为裁剪坐标的过程采用两种不同的方式,每种方式分别定义自己的平截头体。我们可以创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)。

  • 正射投影(Orthographic Projection)

正射投影矩阵定义了一个类似立方体的平截头体,指定了一个裁剪空间,每一个在这空间外面的顶点都会被裁剪。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。所有在使用正射投影矩阵转换到裁剪空间后如果还处于这个平截头体里面的坐标就不会被裁剪。它的平截头体看起来像一个容器:

金沙棋牌官方平台 26

上面的平截头体定义了由宽、高、平面和平面决定的可视的坐标系。正视平截头体直接将平截头体内部的顶点映射到标准化设备坐标系中,因为每个向量的w分量都是不变的;如果w分量等于1.0,则透视划分不会改变坐标的值。

为了创建一个正射投影矩阵,我们利用GLM的构建函数glm::ortho

glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);

前两个参数指定了平截头体的左右坐标,第三和第四参数指定了平截头体的底部和上部。通过这四个参数我们定义了近平面和远平面的大小,然后第五和第六个参数则定义了近平面和远平面的距离。这个指定的投影矩阵将处于这些x,y,z范围之间的坐标转换到标准化设备坐标系中。

正射投影矩阵直接将坐标映射到屏幕的二维平面内,但实际上一个直接的投影矩阵将会产生不真实的结果,因为这个投影没有将透视(Perspective)考虑进去。所以我们需要透视投影矩阵来解决这个问题。

  • 透视投影(Perspective Projection)

离你越远的东西看起来更小,这个神奇的效果我们称之为透视。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:

金沙棋牌官方平台 27

正如你看到的那样,由于透视的原因,平行线似乎在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。
这个投影矩阵不仅将给定的平截头体范围映射到裁剪空间,同样还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被转换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的对象都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内从而作为最后的顶点着色器输出,因此一旦坐标在裁剪空间内,透视划分就会被应用到裁剪空间坐标:

金沙棋牌官方平台 28

每个顶点坐标的分量都会除以它的w分量,得到一个距离观察者的较小的顶点坐标。这是也是另一个w分量很重要的原因,因为它能够帮助我们进行透射投影。最后的结果坐标就是处于标准化设备空间内的。
如果你对研究正射投影矩阵和透视投影矩阵是如何计算的很感兴趣(且不会对数学感到恐惧的话)我推荐这篇由Songho写的文章。
在GLM中可以这样创建一个透视投影矩阵:

glm::mat4 proj = glm::perspective(45.0f, (float)width/(float)height, 0.1f, 100.0f);

glm::perspective
所做的其实就是再次创建了一个定义了可视空间的大的平截头体,任何在这个平截头体外的对象最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被可视化为一个不均匀形状的盒子,在这个盒子内部的每个坐标都会被映射到裁剪空间的点。一张透视平截头体的照片如下所示:

金沙棋牌官方平台 29

  • 它的第一个参数定义了fov的值,它表示的是视野(Field of View),并且设置了观察空间的大小。对于一个真实的观察效果,它的值经常设置为45.0,但想要看到更多结果你可以设置一个更大的值。
  • 第二个参数设置了宽高比,由视口的宽除以高。
  • 第三和第四个参数设置了平截头体的近和远平面。我们经常设置近距离为0.1而远距离设为100.0。所有在近平面和远平面的顶点且处于平截头体内的顶点都会被渲染。

当你把透视矩阵的near值设置太大时(如10.0),OpenGL会将靠近摄像机的坐标都裁剪掉(在0.0和10.0之间),这会导致一个你很熟悉的视觉效果:在太过靠近一个物体的时候视线会直接穿过去。

当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视划分(它仍然有进行透视划分,只是w分量没有被操作(它保持为1)因此没有起作用)。因为正射投影没有使用透视,远处的对象不会显得小以产生神奇的视觉输出。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的应用,或者是那些我们不需要使用投影来转换顶点的情况下。某些如Blender的进行三维建模的软件有时在建模时会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。下面你能够看到在Blender里面使用两种投影方式的对比:

金沙棋牌官方平台 30

你可以看到使用透视投影的话,远处的顶点看起来比较小,而在正射投影中每个顶点距离观察者的距离都是一样的。

3、将obj中数据真正的运用3D对象中去

JavaScript

Text3d.prototype.addFace = function(data) {     this.addIndex(+data[1], +data[4], +data[7], +data[10]);     this.addUv(+data[2], +data[5], +data[8], +data[11]);     this.addNormal(+data[3], +data[6], +data[9], +data[12]); };   Text3d.prototype.addIndex = function(a, b, c, d) {     if(!d) {         this.index.push(a, b, c);     } else {         this.index.push(a, b, c, a, c, d);     } };   Text3d.prototype.addNormal = function(a, b, c, d) {     if(!d) {         this.normal.push(             3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,             3 * this.normalArr[b], 3 * this.normalArr[b] + 1, 3 * this.normalArr[b] + 2,             3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2         );     } else {         this.normal.push(             3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,             3 * this.normalArr[b], 3 * this.normalArr[b] + 1, 3 * this.normalArr[b] + 2,             3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2,             3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,             3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2,             3 * this.normalArr[d], 3 * this.normalArr[d] + 1, 3 * this.normalArr[d] + 2         );     } };   Text3d.prototype.addUv = function(a, b, c, d) {     if(!d) {         this.uv.push(2 * this.uvArr[a], 2 * this.uvArr[a] + 1);         this.uv.push(2 * this.uvArr[b], 2 * this.uvArr[b] + 1);         this.uv.push(2 * this.uvArr[c], 2 * this.uvArr[c] + 1);     } else {         this.uv.push(2 * this.uvArr[a], 2 * this.uvArr[a] + 1);         this.uv.push(2 * this.uvArr[b], 2 * this.uvArr[b] + 1);         this.uv.push(2 * this.uvArr[c], 2 * this.uvArr[c] + 1);         this.uv.push(2 * this.uvArr[d], 2 * this.uvArr[d] + 1);     } };

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Text3d.prototype.addFace = function(data) {
    this.addIndex(+data[1], +data[4], +data[7], +data[10]);
    this.addUv(+data[2], +data[5], +data[8], +data[11]);
    this.addNormal(+data[3], +data[6], +data[9], +data[12]);
};
 
Text3d.prototype.addIndex = function(a, b, c, d) {
    if(!d) {
        this.index.push(a, b, c);
    } else {
        this.index.push(a, b, c, a, c, d);
    }
};
 
Text3d.prototype.addNormal = function(a, b, c, d) {
    if(!d) {
        this.normal.push(
            3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,
            3 * this.normalArr[b], 3 * this.normalArr[b] + 1, 3 * this.normalArr[b] + 2,
            3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2
        );
    } else {
        this.normal.push(
            3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,
            3 * this.normalArr[b], 3 * this.normalArr[b] + 1, 3 * this.normalArr[b] + 2,
            3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2,
            3 * this.normalArr[a], 3 * this.normalArr[a] + 1, 3 * this.normalArr[a] + 2,
            3 * this.normalArr[c], 3 * this.normalArr[c] + 1, 3 * this.normalArr[c] + 2,
            3 * this.normalArr[d], 3 * this.normalArr[d] + 1, 3 * this.normalArr[d] + 2
        );
    }
};
 
Text3d.prototype.addUv = function(a, b, c, d) {
    if(!d) {
        this.uv.push(2 * this.uvArr[a], 2 * this.uvArr[a] + 1);
        this.uv.push(2 * this.uvArr[b], 2 * this.uvArr[b] + 1);
        this.uv.push(2 * this.uvArr[c], 2 * this.uvArr[c] + 1);
    } else {
        this.uv.push(2 * this.uvArr[a], 2 * this.uvArr[a] + 1);
        this.uv.push(2 * this.uvArr[b], 2 * this.uvArr[b] + 1);
        this.uv.push(2 * this.uvArr[c], 2 * this.uvArr[c] + 1);
        this.uv.push(2 * this.uvArr[d], 2 * this.uvArr[d] + 1);
    }
};

这里我们考虑到兼容obj文件中f(ace)行中4个值的情况,导出obj文件中可以强行选择只有三角面,不过我们在代码中兼容一下比较稳妥

Pe在near平面的投影为:
Xep = n* Xe/(-Ze) (n为近平面到eye距离).
注意这里OPENGL 右手系camera space是Z轴负方向为眼睛看的方向。当计算投影时,x,y都应该除以一个正的数值。所以Ze取负。

把它们都组合到一起

我们为上述的每一个步骤都创建了一个转换矩阵:模型矩阵、观察矩阵和投影矩阵。一个顶点的坐标将会根据以下过程被转换到裁剪坐标:

金沙棋牌官方平台 31

注意每个矩阵被运算的顺序是相反的(记住我们需要从右往左乘上每个矩阵)。最后的顶点应该被赋予顶点着色器中的gl_Position
且OpenGL将会自动进行透视划分和裁剪。

然后呢?
顶点着色器的输出需要所有的顶点都在裁剪空间内,而这是我们的转换矩阵所做的。OpenGL然后在裁剪空间中执行透视划分从而将它们转换到标准化设备坐标。OpenGL会使用glViewPort
内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中屏幕是800 *600)。这个过程称为视口转换。

4、旋转平移等变换

物体全部导入进去,剩下来的任务就是进行变换了,首先我们分析一下有哪些动画效果
因为我们模拟的是一个宇宙,3D文字就像是星球一样,有公转和自转;还有就是我们导入的obj文件都是基于(0,0,0)点的,所以我们还需要把它们进行平移操作
先上核心代码~

JavaScript

...... this.angle += this.rotate; // 自转的角度   var s = Math.sin(this.angle); var c = Math.cos(this.angle);   // 公转相关数据 var gs = Math.sin(globalTime * this.revolution); // globalTime是全局的时间 var gc = Math.cos(globalTime * this.revolution);     webgl.uniformMatrix4fv(     this.program.uMMatrix, false, mat4.multiply([             gc,0,-gs,0,             0,1,0,0,             gs,0,gc,0,             0,0,0,1         ], mat4.multiply(             [                 1,0,0,0,                 0,1,0,0,                 0,0,1,0,                 this.x,this.y,this.z,1 // x,y,z是偏移的位置             ],[                 c,0,-s,0,                 0,1,0,0,                 s,0,c,0,                 0,0,0,1             ]         )     ) );

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
......
this.angle += this.rotate; // 自转的角度
 
var s = Math.sin(this.angle);
var c = Math.cos(this.angle);
 
// 公转相关数据
var gs = Math.sin(globalTime * this.revolution); // globalTime是全局的时间
var gc = Math.cos(globalTime * this.revolution);
 
 
webgl.uniformMatrix4fv(
    this.program.uMMatrix, false, mat4.multiply([
            gc,0,-gs,0,
            0,1,0,0,
            gs,0,gc,0,
            0,0,0,1
        ], mat4.multiply(
            [
                1,0,0,0,
                0,1,0,0,
                0,0,1,0,
                this.x,this.y,this.z,1 // x,y,z是偏移的位置
            ],[
                c,0,-s,0,
                0,1,0,0,
                s,0,c,0,
                0,0,0,1
            ]
        )
    )
);

一眼望去uMMatrix(模型矩阵)里面有三个矩阵,为什么有三个呢,它们的顺序有什么要求么?
因为矩阵不满足交换率,所以我们矩阵的平移和旋转的顺序十分重要,先平移再旋转和先旋转再平移有如下的差异
(下面图片来源于网络)
先旋转后平移:金沙棋牌官方平台 32
先平移后旋转:金沙棋牌官方平台 33
从图中明显看出来先旋转后平移是自转,而先平移后旋转是公转
所以我们矩阵的顺序一定是 公转 * 平移 * 自转 * 顶点信息(右乘)
具体矩阵为何这样写可见上一篇矩阵入门文章
这样一个3D文字的8大行星就形成啦

这么做的目的是为了让在view space中所有在视锥平截体内的点,X数值投影到近平面上,都在near平面上的left到right。
接下来是求最后在device space里x,y,z的数值,device space是坐标为-1到1的立方体。其中x/2+0.5,y/2+0.5 分别再乘以窗口长宽就是屏幕上像素的位置。那么屏幕这个像素的Z数值就可以按以下方法求出:
需要把view space中(left,right,top,bottom,near,far)的frustum转换到长宽为(-1,1)的正方体内,就是说,把Xe,Ye,Ze都映射到[-1,1]的范围内。前面已经求出了Xe在camera space near平面上的投影Xep。这个投影的数值就是在frustum平截体near平面上的位置。
把平截体near平面映射到-1,1的正方形很简单,X方向按如下映射:
Xp = (Xep - left)*2/(right-left) -1  。

进入三维

既然我们知道了如何将三维坐标转换为二维坐标,我们可以开始将我们的对象展示为三维对象而不是目前我们所展示的缺胳膊少腿的二维平面。

在开始进行三维画图时,我们首先创建一个模型矩阵。这个模型矩阵包含了平移、缩放与旋转,我们将会运用它来将对象的顶点转换到全局世界空间。让我们平移一下我们的平面,通过将其绕着x轴旋转使它看起来像放在地上一样。这个模型矩阵看起来是这样的:

glm::mat4 model;model = glm::rotate(model, -55.0f, glm::vec3(1.0f, 0.0f, 0.0f));

通过将顶点坐标乘以这个模型矩阵我们将该顶点坐标转换到世界坐标。我们的平面看起来就是在地板上的因此可以代表真实世界的平面。

接下来我们需要创建一个观察矩阵。我们想要在场景里面稍微往后移动以使得对象变成可见的(当在世界空间时,我们位于原点(0,0,0))。要想在场景里面移动,思考下面的问题:

  • 将摄像机往后移动跟将整个场景往前移是一样的。

这就是观察空间所做的,我们以相反于移动摄像机的方向移动整个场景。因为我们想要往后移动,并且OpenGL是一个右手坐标系(Right-handed System)所以我们沿着z轴的正方向移动。我们会通过将场景沿着z轴负方向平移来实现这个。它会给我们一种我们在往后移动的感觉。

右手坐标系(Right-handed System)
按照约定,OpenGL是一个右手坐标系。最基本的就是说正x轴在你的右手边,正y轴往上而正z轴是往后的。想象你的屏幕处于三个轴的中心且正z轴穿过你的屏幕朝向你。坐标系画起来如下:

金沙棋牌官方平台 34


为了理解为什么被称为右手坐标系,按如下的步骤做:
张开你的右手使正y轴沿着你的手往上。
使你的大拇指往右。
使你的食指往上。
向下90度弯曲你的中指。

如果你都正确地做了,那么你的大拇指朝着正x轴方向,食指朝着正y轴方向,中指朝着正z轴方向。如果你用左手来做这些动作,你会发现z轴的方向是相反的。这就是有名的左手坐标系,它被DirectX广泛地使用。注意在标准化设备坐标系中OpenGL使用的是左手坐标系(投影矩阵改变了惯用手的习惯)。

在下一个教程中我们将会详细讨论如何在场景中移动。目前的观察矩阵是这样的:

glm::mat4 view;// 注意,我们将场景朝着我们(摄像机)要移动的反方向移动。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f)); 

最后我们需要做的是定义一个投影矩阵。我们想要在我们的场景中使用透视投影所以我们声明的投影矩阵是像这样的:

glm::mat4 projection;
projection = glm::perspective(45.0f, screenWidth / screenHeight, 0.1f, 100.0f);

再重复一遍,在glm指定角度的时候要注意。这里我们将参数fov设置为45度,但有些GLM的实现是将fov当成弧度,在这种情况你需要使用glm::radians(45.0)
来设置。

既然我们创建了转换矩阵,我们应该将它们传入着色器。

首先,我们在顶点着色器中声明多个uniform类型的转换矩阵,然后与顶点坐标矩阵相乘。

#version 330 core
layout (location = 0) in vec3 position;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
    // 注意从右向左读 
    gl_Position = projection * view * model * vec4(position,     1.0f);
    ...
}

我们应该将矩阵传入着色器(这通常在每次渲染的时候传入,因为转换矩阵很可能变化很大):

GLint modelLoc = glGetUniformLocation(ourShader.Program, "model");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
... // 视图矩阵和投影矩阵与之类似

现在我们的顶点坐标通过模型、视图和投影矩阵来转换,最后的对象应该是:

  • 往后向地板倾斜。
  • 离我们有点距离。
  • 由透视展示(顶点越远,变得越小)

让我们检查一下结果是否满足这些要求:

金沙棋牌官方平台 35

它看起来就像是一个三维的平面,是静止在一些虚构的地板上的。如果你不是得到相同的结果,请检查下完整的源代码 以及顶点和片段着色器。项目文件。

4、装饰星星

光秃秃的几个文字肯定不够,所以我们还需要一点点缀,就用几个点当作星星,非常简单
注意默认渲染webgl.POINTS是方形的,所以我们得在fragment shader中加工处理一下

JavaScript

precision highp float;   void main() {     float dist = distance(gl_PointCoord, vec2(0.5, 0.5)); // 计算距离     if(dist < 0.5) {         gl_FragColor = vec4(0.9, 0.9, 0.8, pow((1.0 - dist * 2.0), 3.0));     } else {         discard; // 丢弃     } }

1
2
3
4
5
6
7
8
9
10
precision highp float;
 
void main() {
    float dist = distance(gl_PointCoord, vec2(0.5, 0.5)); // 计算距离
    if(dist < 0.5) {
        gl_FragColor = vec4(0.9, 0.9, 0.8, pow((1.0 - dist * 2.0), 3.0));
    } else {
        discard; // 丢弃
    }
}

当Xep在left到right变化时,Xp在-1到1变化。
因为显卡硬件(GPU)的扫描线差值都是透视矫正的,考虑投影后,顶点Xp和Yp都是 1/(-Ze) 的线性关系。那么在device单位立方体内,Zp 的范围在[-1,1]之间,Zp也就是Z BUFFER的数值。根据前面推导,要保证透视校正,Zp也是以1/(-Ze)的线性关系。即,总能找到一个公式,使得
Zp = A* 1/(-Ze) + B。

更多的3D

要渲染一个立方体,我们一共需要36个顶点(6个面 x 每个面有2个三角形组成 x 每个三角形有3个顶点),这36个顶点的位置你可以从这里获取。注意,这一次我们省略了颜色值,因为这次我们只在乎顶点的位置和,我们使用纹理贴图。

为了好玩,我们将让立方体随着时间旋转:

model = glm::rotate(model, (GLfloat)glfwGetTime() * 50.0f, glm::vec3(0.5f, 1.0f, 0.0f));

然后我们使用glDrawArrays
来画立方体,这一次总共有36个顶点。

glDrawArrays(GL_TRIANGLES, 0, 36);

如果一切顺利的话绘制效果将与下面的类似:

演示视频

项目代码

这有点像一个立方体,但又有种说不出的奇怪。立方体的某些本应被遮挡住的面被绘制在了这个立方体的其他面的上面。之所以这样是因为OpenGL是通过画一个一个三角形来画你的立方体的,所以它将会覆盖之前已经画在那里的像素。因为这个原因,有些三角形会画在其它三角形上面,虽然它们本不应该是被覆盖的。

幸运的是,OpenGL存储深度信息在z缓冲区(Z-buffer)里面,它允许OpenGL决定何时覆盖一个像素何时不覆盖。通过使用z缓冲区我们可以设置OpenGL来进行深度测试。

结语

需要关注的是这里我用了另外一对shader,此时就涉及到了关于是用多个program shader还是在同一个shader中使用if statements,这两者性能如何,有什么区别
这里将放在下一篇webgl相关优化中去说

本文就到这里啦,有问题和建议的小伙伴欢迎留言一起讨论~!

1 赞 收藏 评论

金沙棋牌官方平台 36

也就是说,不管A和B是什么,在Z BUFFER中的数值,总是和物体顶点在camera space中的 -1/Ze 成线性的关系。 我们要做的就是求A 和B。
求A和B,我们利用以下条件:
当Ze = far 时, Zp = 1
当Ze = near时,Zp = -1(在OPENGL下是这样。在D3D下是Zp =0)
这实际是个2元一次方程。有了两个已知解,可以求出A和B。OPENGL下,
A = 2nf/(f-n), B = (f+n)/(f-n)

z缓冲区

OpenGL存储它的所有深度信息于z缓冲区中,也被称为深度缓冲区(Depth Buffer)。GLFW会自动为你生成这样一个缓冲区 (就如它有一个颜色缓冲区来存储输出图像的颜色)。
深度存储在每个片段里面(作为片段的z值)当片段像输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较然后如果当前的片段在其它片段之后它将会被丢弃,然后重写。这个过程称为深度测试(Depth Testing)并且它是由OpenGL自动完成的。

如果我们想要确定OpenGL是否真的执行深度测试,首先我们要告诉OpenGL我们想要开启深度测试;而这通常是默认关闭的。我们通过glEnable
函数来开启深度测试。glEnable
和glDisable
函数允许我们开启或关闭某一个OpenGL的功能。该功能会一直是开启或关闭的状态直到另一个调用来关闭或开启它。现在我们想开启深度测试就需要开启GL_DEPTH_TEST

glEnable(GL_DEPTH_TEST);

既然我们使用了深度测试我们也想要在每次重复渲染之前清除深度缓冲区(否则前一个片段的深度信息仍然保存在缓冲区中)。就像清除颜色缓冲区一样,我们可以通过在glclear
函数中指定DEPTH_BUFFER_BIT
位来清除深度缓冲区:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

演示视频

就是这样!一个开启了深度测试,各个面都是纹理,并且还在旋转的立方体!如果你的程序有问题可以到这里下载源码进行比对。

这样一来,我们就知道Z BUFFER的数值如何求得。先把物体顶点世界坐标Pw变换到camera space中得到Pe。然后再经过透视投影变换,求得Ze->Zp的数值。把这个数值填入Z buffer,就是显卡用来比较哪个像素在前,哪个像素在后的依据了。
这也就是为什么near和far设置不合适的时候,很容易发生z fighting。一般情况下,离屏幕很近的一段距离,已经用掉了90%的z 浮点精度。在用于渲染视角里中远距离的场景时,深度的判别只靠剩下的10%精度来进行。
具体推导可以看看 

更多的立方体

现在我们想在屏幕上显示10个立方体。每个立方体看起来都是一样的,区别在于它们在世界的位置及旋转角度不同。立方体的图形布局已经定义好了,所以当渲染更多物体的时候我们不需要改变我们的缓冲数组和属性数组,我们唯一需要做的只是改变每个对象的模型矩阵来将立方体转换到世界坐标系中。

首先,让我们为每个立方体定义一个平移向量来指定它在世界空间的位置。我们将要在glm::vec3
数组中定义10个立方体位置向量。

glm::vec3 cubePositions[] = 
{ 
glm::vec3( 0.0f, 0.0f, 0.0f), 
glm::vec3( 2.0f, 5.0f, -15.0f), 
glm::vec3(-1.5f, -2.2f, -2.5f), 
glm::vec3(-3.8f, -2.0f, -12.3f), 
glm::vec3( 2.4f, -0.4f, -3.5f), 
glm::vec3(-1.7f, 3.0f, -7.5f), 
glm::vec3( 1.3f, -2.0f, -2.5f), 
glm::vec3( 1.5f, 2.0f, -2.5f), 
glm::vec3( 1.5f, 0.2f, -1.5f), 
glm::vec3(-1.3f, 1.0f, -1.5f) 
};

现在,在循环中,我们调用glDrawArrays
10次,在我们开始渲染之前每次传入一个不同的模型矩阵到顶点着色器中。我们将会创建一个小的循环来通过一个不同的模型矩阵重复渲染我们的对象10次。注意我们也传入了一个旋转参数到每个箱子中:

glBindVertexArray(VAO);
for(GLuint i = 0; i < 10; i++)
{ 
    glm::mat4 model; 
    model = glm::translate(model, cubePositions[i]); 
    GLfloat angle = 20.0f * i; 
    model = glm::rotate(model, angle, glm::vec3(1.0f, 0.3f, 0.5f)); 
    glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model)); 
    glDrawArrays(GL_TRIANGLES, 0, 36);
}
glBindVertexArray(0);

这个代码将会每次都更新模型矩阵然后画出新的立方体,如此总共重复10次。然后我们应该就能看到一个拥有10个正在奇葩旋转着的立方体的世界。

金沙棋牌官方平台 37

Image 044.png

项目代码在这

逐渐被D3D抛弃的W BUFFER,场景远近与W数值是线性的。即,100米的距离,在near=1 far=101时,每一米在D3D W BUFFER的表示中,就是差不多 1/100 那么大。但是在Z BUFFER中就完全不是均匀分配。

练习

  • 对GLM的投影函数中的FoV和aspect-ratio参数进行试验。看能否搞懂它们是如何影响透视平截头体的。

关于FoV参数

金沙棋牌官方平台 38

左侧:
projection = glm::perspective (glm::radians (30.0f), (float)WIDTH / (float)HEIGHT, 0.1f, 100.0f);
右侧:
projection = glm::perspective (glm::radians (45.0f), (float)WIDTH / (float)HEIGHT, 0.1f, 100.0f);

关于aspect-ratio参数

金沙棋牌官方平台 39

左侧:
projection = glm::perspective (glm::radians (45.0f), 800.0f / 300.0f, 0.1f, 100.0f);
右侧:
projection = glm::perspective (glm::radians (45.0f), 800.0f / 600.0f, 0.1f, 100.0f);

  • 将观察矩阵在各个方向上进行平移,来看看场景是如何改变的。注意把观察矩阵当成摄像机对象。

金沙棋牌官方平台 40

左侧:
view = glm::translate (view, glm::vec3 (0.0f, 0.0f, -6.0f));
右侧:
view = glm::translate (view, glm::vec3 (0.0f, 0.0f, -3.0f));

金沙棋牌官方平台 41

左侧:
view = glm::translate (view, glm::vec3 (0.0f, 1.0f, -3.0f));
右侧:
view = glm::translate (view, glm::vec3 (1.0f, 0.0f, -3.0f));

  • 只使用模型矩阵每次只让3个箱子旋转(包括第1个)而让剩下的箱子保持静止。

代码

下面考虑perspective projection matrix。
根据线性代数原理,我们知道无法用一个3x3的matrix对顶点(x,y,z)进行透视映射。无法通过一个3X3的矩阵得到 x/z 的形式。进而引进齐次坐标矩阵---4x4 matrix。顶点坐标(x,y,z,w)。
齐次坐标中,顶点(x, y, z, w)相当于(x/w, y/w, z/w, 1)。 看到这个顶点坐标,我们会联想到前面我们最终求出的z buffer数值Zp和单位device space中的Xp坐标。利用矩阵乘法,可以得到一个矩阵Mp,使得(Xe,Ye,Ze,1)的顶点坐标转换为齐次坐标规一化后的 (Xp,Yp,Zp,1) 。  即:
Vp = Mp * Ve  .
Vp是单位设备坐标系的顶点坐标(Xp,Yp,Zp,1)。Ve是camera space顶点坐标(Xe,Ye,Ze,1)。

考虑
Xp = (Xep - left)*2/(right-left) -1      (Xep  = -n* Xe/Ze)
Yp = (Yep - left)*2/(right-left) -1      (Yep  = -n* Ye/Ze)
Zp = A* 1/Ze + B

为了得到4X4 MATRIX,我们需要把(Xp,Yp,Zp,1)转为齐次坐标 (-Xp*Ze, -Yp*Ye, -Zp*Ze, -Ze) 。然后由矩阵乘法公式和上面已知坐标,就可以得到PROJECTION MATRIX。

Xp*(-Ze) = M0  M1  M2  M3                  Xe
Yp*(-Ze) = M4  M5  M6  M7        x         Ye
Zp*(-Ze) = M8  M9  M10 M11                 Ze
-Ze    = M12 M13 M14 M15                   1

这里拿 M0, M1, M2, M3 的求解来举例:
M0* Xe + M1* Ye + M2* Ze + M3= (-Ze)*(-n*Xe/Ze-left )*2/(right-left) +Ze
M1 = 2n/(right-left)
M2 = 0
M3 = (right+left)/(right-left)
M4 = 0

最后得到Opengl 的 Perspective Projection Matrix:

[ 2n/(right-left)   0                                  (right+left)/(right-left)    0                            ]
[ 0                 2*near/(top-bottom)                (top+bottom)/(top-bottom)    0                            ]
[ 0                 0                                  -(far+near)/(far-near)       -2far*near/(far-near)        ]
[ 0                 0                                  -1                           0                            ]

D3D 的左手系透视投影矩阵和OPENGL有以下区别。
1, D3D device space 不是个立方体,是个扁盒子。z的区间只有[0,1] 。x,y区间还是[-1,1]
2,D3D的camera space Z轴朝向正方向,计算camera space中投影时不用 Xep = n*Xe/(-Ze), 而是 Xep = n*Xe/Ze
3,D3D中,从camera space的视椎平截体到device space的单位体(扁盒子)摄影,采用了很奇怪的作法。把frustum右上角映射为device单位体的(0,0,0)位置

请RE D3D PERSPECTIVE PROJECTION MATRIX推导过程~

后面若有时间继续 透视校正texture mapping,phong shading以及bump mapping等per-pixel processing光栅化

本文由金沙棋牌发布于金沙棋牌官方平台,转载请注明出处:透视投影,教你用webgl快速创建一个小世界

关键词: