进入三维世界

99 阅读52分钟

前言

佛曰:一花一叶一世界,一草一木一浮生。

在webgl的三维世界里亦是如此,因为它本身就是对真实世界的映射。

当我们对向量的各种运算得心应手的时候,我们便可以在一个世界里自由变换。

当我们对矩阵的各种运算得心应手的时候,我们便可以看见那一花一叶里的世界,望穿那天外的星罗万象。

我们前几章所说的知识,大部分都是二维的。

虽然在说矩阵的时候,给大家说过模型矩阵和视图矩阵,但那是从矩阵算法的角度来看三维世界的。

接下来这一篇,我们就真正的进入三维世界。

课堂目标 自由变换三维物体 以用户视角进入三维世界 控制三维可视空间 处理物体的前后关系 绘制三维物体 知识点 世界坐标系 本地坐标系 欧拉旋转 四元数旋转 模型矩阵 视图矩阵 投影矩阵 第一章 世界坐标系和本地坐标系 1-基本概念

我们既然要进入三维世界,必须要先有坐标系的概念,而不要只想着如何让物体飞天遁地。

坐标系按照层级分为:

世界坐标系 本地坐标系

当然,坐标系还可以按照类型来分,比如:直角坐标系、极坐标系等,这不是我们这篇要说的重点。

接下来,我们重点来说世界坐标系和本地坐标系。

我通过一个神的传说,给大家引出世界坐标系和本地坐标系的概念。

天地混沌之时,宇宙只是一个蛋。

这个蛋之所在,就是世界坐标系的原点所在。

十万八千年后,盘古一斧将蛋劈开,这个蛋没了,宇宙也变得无穷无尽了,其中万物初生,世界坐标系里的坐标轴也应运而生,以此定位万物。

然则,宇宙之中,无论是日月星辰,还是花鸟鱼虫,它们皆可自成一界,在这一界中,它们都有着自己的坐标系。

比如:

北京在东经116°20′、北纬39°56′ 上,这个位置就是北京在地球的本地坐标系里的位置。

那北京在宇宙里的世界坐标位是什么呢?这需要知道宇宙的坐标原点在哪里。

至于宇宙的坐标原点在哪里,我就不再做深度探讨了,不然能扯到释迦摩尼的缘起性空和爱因斯坦的相对论上去。

接下来拿变换举例子。

2-认识世界坐标系、本地坐标系中的点位关系

已知:

世界坐标系[O1;i1,j1,k1] 点P 点P所处的本地坐标系是[O2;i2,j2,k2] 世界坐标系[O1;i1,j1,k1]∋本地坐标系[O2;i2,j2,k2]

解释一下:

[O;i,j,k]中:

O 是坐标原点 i,j,k 是坐标向量

这是空间直角坐标系的书写方式,大家可在高中数学的空间几何部分找到。

初学three.js 的同学,往往很难玩转其中矩阵变换、欧拉、四元数、世界坐标位、本地坐标位等。

若大家把我当前所说的这块搞懂了,可以攻克一个很重要的难点。

接下我们继续围绕点P 来说事。

提问1:

我说点P 的坐标位是(x,y,z),可否找到点P?

答:不可。

因为我没说(x,y,z) 是在世界坐标系[O1;i1,j1,k1]里的位置,还是在本地坐标系是[O2;i2,j2,k2]里的位置。

提问2:

点P 的世界坐标位是(x,y,z),可否找到点P?

答:可

接下来我们说重点啦。

提问3:

点P 的本地坐标位是(x,y,z),可否找到点P?若可以,求点P的世界位。

答:可

解点P的世界位:

根据空间向量分解定理。

由世界坐标系[O1;i1,j1,k1]可解析出四维矩阵m1:

[ i1.x,j1.x,k1.x,0, i1.y,j1.y,k1.y,0, i1.z,j1.z,k1.z,0, O1.x,O1.y,O1.z,1 ]

同理,由本地坐标系[O2;i2,j2,k2]可解析出四维矩阵m2:

[ i2.x,j2.x,k2.x,0, i2.y,j2.y,k2.y,0, i2.z,j2.z,k2.z,0, O2.x,O2.y,O2.z,1 ]

点P的世界位是:

m1m2(x,y,z)

对于我上面的说法对不对,大家一定要保持怀疑的态度,用批判否定的眼光看问题,不唯上,不唯书,只为实。

第二章 深入认知三维世界

接下来,我们借助three.js 来验证我们的推理。

若我们之前的推理和three.js 里的一致,那就基本没问题了,毕竟这种底层原理,在three.js 里一般是不会错的。

1-用位移矩阵做实验 1-1-示例

已知:

宇宙universe 宇宙的本地坐标系是[O1;i1,j1,k1] O1(0,0,0) i1(1,0,0) j1(0,1,0) k1(0,0,1) 宇宙包含万物,其本地坐标系就是万物的世界坐标系 银河系galaxy 银河系的本地坐标系是[O2;i2,j2,k2] O2(1,2,3) i2(1,0,0) j2(0,1,0) k2(0,0,1) 太阳sun 太阳在银河系内的本地坐标位是P2(4,5,6) 太阳∈银河系∈宇宙

求:太阳的世界位P1

解:

由宇宙坐标系[O1;i1,j1,k1]解矩阵m1:

[ 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 ]

由银河系[O2;i2,j2,k2]解矩阵m2:

[ 1,0,0,0, 0,1,0,0, 0,0,1,0, 1,2,3,1 ]

点P的世界坐标位是:

P1=m1m2(4,5,6) P1=(1+4,2+5,3+6) P1=(5,7,9)

接下来我们拿three.js验证一下

1-2-验证

1.从three.js 中引入我们要用到的方法

import { Group, Matrix4, Object3D,Scene, Vector3, } from 'unpkg.com/three/build…';

2.基于世界坐标系和本地坐标系构建矩阵

//世界坐标系-宇宙 const m1 = new Matrix4() m1.elements = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]

//本地坐标系-银河系 const m2 = new Matrix4() m2.elements = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1 ]

3.声明太阳在银河系内本地坐标P2

//本地坐标位-太阳 const P2 = new Vector3(4, 5, 6)

4.创造一个宇宙

const universe = new Scene() universe.applyMatrix4(m1)

applyMatrix4() 通过四维矩阵赋予对象坐标系

5.同理,创造银河系

//银河系 const galaxy = new Group() galaxy.applyMatrix4(m2)

6.创造太阳

const sun = new Object3D() sun.position.copy(P2)

太阳的position属性便是其在银河系中的相对位

7.宇宙、银河系和太阳的包含关系:太阳∈银河系∈宇宙

galaxy.add(sun) universe.add(galaxy)

8.计算太阳的在宇宙中的世界位

const P1 = new Vector3() sun.getWorldPosition(P1) console.log(P1); //{x:5,y:7,z:9}

这个结果和我们之前推理的是一样的。

接下来咱们借此深度探究一下位移的法则。

2-位移法则

如果我们不想求太阳的位置,而是想求太阳系内的地球的位置,那是否还可以按照我们之前的思路来求解?

答案是肯定的。

2-1-示例

调整一下之前的已知条件。

把太阳改成太阳系solar

太阳系的本地坐标系是[O3;i3,j3,k3]

O3(4,5,6) i3(1,0,0) j3(0,1,0) k3(0,0,1)

地球earth

地球在太阳系内的本地坐标位是P3(7,8,9)

地球∈太阳系∈银河系∈宇宙

求:地球的世界坐标位P1

解:

由太阳系的本地坐标系可得矩阵m3:

[ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 4, 5, 6, 1 ]

求地球的世界坐标位P1:

P1=m1m2m3*(7,8,9) P1=(1+4+7,2+5+8,3+6+9) P1=(12,15,18)

2-2-验证

按照之前的原理用three.js验证一番:

import { Group, Matrix4, Object3D, Scene, Vector3, } from 'unpkg.com/three/build…';

//世界坐标系-宇宙 const m1 = new Matrix4() m1.elements = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]

//本地坐标系-银河系 const m2 = new Matrix4() m2.elements = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1 ]

//本地坐标系-太阳系 const m3 = new Matrix4() m3.elements = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 4, 5, 6, 1 ]

//本地坐标位-地球 const P3 = new Vector3(7, 8, 9)

//宇宙(世界坐标系是宇宙的本地坐标系) const universe = new Scene() universe.applyMatrix4(m1) console.log(universe.position) console.log(universe.matrix)

//银河系 const galaxy = new Group() galaxy.applyMatrix4(m2)

//太阳系 const solar = new Group() solar.applyMatrix4(m3)

//地球 const earth = new Object3D() earth.position.copy(P3)

//包含关系 solar.add(earth) galaxy.add(solar) universe.add(galaxy)

//点P的世界位 const P1 = new Vector3() earth.getWorldPosition(P1) console.log(P1); //{x: 12, y: 15, z: 18}

2-3-推理

我们可以从上面的结论中得到一个规律:

当一点P和宇宙之间存在n层嵌套

点P的本地坐标位是Pn

第n层世界的本地坐标系所对应的矩阵是mn

则点P的世界位P1是:

P1=m1m2……mnpn

上面的公式,我们就暂且叫它“本地坐标转世界坐标公式”了,我不知其有没有学名,就先这么叫着了。

接下来,我们再思考一个问题。

之前我们对所有坐标系只是进行了位移操作,那如果我们对其做了缩放和旋转操作,上式是否成立呢?

3-缩放法则 3-1-示例

修改之前已知条件:

在银河系的本地坐标系[O2;i2,j2,k2]中,让j2是单位向量的2倍:

O2(1,2,3) i2(1,0,0) j2(0,2,0) k2(0,0,1)

在太阳系的本地坐标系[O3;i3,j3,k3],让k3是单位向量的3倍:

O3(4,5,6) i3(1,0,0) j3(0,1,0) k3(0,0,3)

求:地球的世界坐标位P1

解:

由银河系的本地坐标系可得矩阵m2:

[ 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1 ]

由太阳系的本地坐标系可得矩阵m3:

[ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 3, 0, 4, 5, 6, 1 ]

求地球的世界坐标位P1:

P1=m1m2m3*(7,8,9) m1m2m3=[ 1, 0, 0, 0, 0, 2, 0, 0 0, 0, 3, 0 4+1,25+2,6+3,1 ] m1m2*m3=[ 1,0, 0,0, 0,2, 0,0, 0,0, 3,0, 5,12,9,1 ] P1=(7+5,16+12,27+9) P1=(12,28,36)

3-2-测试

基于“位移法则”的three.js代码改改:

//本地坐标系-银河系 const m2 = new Matrix4() m2.elements = [ 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1 ]

//本地坐标系-太阳系 const m3 = new Matrix4() m3.elements = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 3, 0, 4, 5, 6, 1 ]

运行代码,可得到和我们刚才计算的一样的结果。

由此可见,当坐标系发生缩放时,本地坐标转世界坐标公式依旧成立

接下咱们再说旋转。

4-旋转法则 4-1-示例

修改之前已知条件:

让银河系的本地坐标系[O2;i2,j2,k2]绕j2轴逆时针旋转20°。

设:c2=cos(-20°),s2=sin(-20°)

则:

O2(1,2,3) i2(c2,0,-s2) j2(0,1,0) k2(s2,0,c2)

让太阳系的本地坐标系[O3;i3,j3,k3]绕k3轴逆时针旋转30°

设:c3=cos(30°),s3=sin(30°)

则:

O3(4,5,6) i3(c3,-s3,0) j3(s3,c3,0) k3(0,0,1)

求:地球的世界坐标位P1

解:

由银河系的本地坐标系可得矩阵m2:

[ c2, 0, s2, 0, 0, 1, 0, 0, -s2,0, c2, 0, 1, 2, 3, 1 ]

由太阳系的本地坐标系可得矩阵m3:

[ c3, s3, 0, 0, -s3, c3, 0, 0, 0, 0, 1, 0, 4, 5, 6, 1 ]

求地球的世界坐标位P1:

P1=m1m2m3*(7,8,9) m1m2m3=[ c2c3, s3, s2c3, 0, -c2s3, c3, -s2s3, 0, -s2, 0, c2, 0, c24-s26+1,5+2,s24+c26+3,1 ] P1=(11.826885919330648,17.428203230275507,15.02200238270646)

注,上式很难像之前那样心算,可以直接用计算机算:

//让银河系的本地坐标系[O2;i2,j2,k2]绕j2轴逆时针旋转20° const ang2 = -20 * Math.PI / 180 const c2 = Math.cos(ang2) const s2 = Math.sin(ang2)

//让太阳系的本地坐标系[O3;i3,j3,k3]绕k3轴逆时针旋转30° const ang3 = 30 * Math.PI / 180 const c3 = Math.cos(ang3) const s3 = Math.sin(ang3)

const m=new Matrix4() m.elements = [ c2 * c3, s3, s2 * c3, 0, -c2 * s3, c3, -s2 * s3, 0, -s2, 0, c2, 0, c2 * 4 - s2 * 6 + 1, 5 + 2, s2 * 4 + c2 * 6 + 3, 1 ] const P1 = P3.applyMatrix4(m) console.log(P1);

4-2-验证

基于“位移法则”的three.js代码改改:

//本地坐标系-银河系 const ang2 = 20 * Math.PI / 180 const m2 = new Matrix4() m2.makeRotationY(ang2) m2.setPosition(1, 2, 3)

//本地坐标系-太阳系 const ang3 = 30 * Math.PI / 180 const m3 = new Matrix4() m3.makeRotationZ(ang3) m3.setPosition(4, 5, 6)

运行代码,可得到和我们刚才计算的一样的结果。

由此可见,当坐标系发生旋转时,本地坐标转世界坐标公式依旧成立

然而,细心的同学可能会发现一个问题:

我在旋转矩阵的时候,只是在让矩阵绕xyz轴的某一个坐标向量进行旋转。

那我能不能让矩阵绕任意向量旋转呢?

亦或者,能不能先绕x旋转angX度,再绕y轴旋转angY度?

这肯定是可以的,接下来我们就对旋转法则进行深度探索。

第三章 旋转法则之深度探索

首先我们要知道,物体旋转的复杂程度是位移和缩放的n多倍。

我们以前在旋转物体时,只是让其绕坐标轴x|y|z 旋转。

然而,在实际项目开发中,我们会有其它的旋转需求。

比如:

欧拉Euler:让物体基于世界坐标系绕x轴旋转a°,然后绕本地坐标系y轴旋转b°,最后绕本地坐标系z轴旋转c°。

四元数Quaternion:让物体绕任意一轴旋转a°。

在说复杂旋转之前,我们需要对旋转的方向有一个透彻的认知,所以我先简单说一下单轴逆时针旋转。

1-顶点绕单轴逆时针旋转

在右手坐标系的逆时针旋转里,绕y轴的逆时针旋转有点特别。

绕y轴旋转时,x轴正半轴是起始轴,即x轴正半轴的弧度为0。

一顶点绕y轴逆时针旋转时,旋转量越大,弧度值越小。

而绕其它两个轴旋转时,则与其相反:

一顶点绕x轴或z轴逆时针旋转时,旋转量越大,弧度值越大。

这就是为什么我让银河系的本地坐标系[O2;i2,j2,k2]绕j2轴逆时针旋转20°时,是通过-20°取的sin值和cos值。

这个推理,我们可以通过three.js的Matrix4对象的makeRotationX()、makeRotationY()、makeRotationZ() 来核对一下。

//30° const ang = 30 * Math.PI / 180 //three.js四维矩阵对象 const m = new Matrix4()

//绕x轴逆时针旋转30° { //three.js 旋转 m.makeRotationX(ang) console.log(...m.elements);

//手动旋转
const c = Math.cos(ang)
const s = Math.sin(ang)
console.log(
    1, 0, 0, 0,
    0, c, s, 0,
    0, -s, c, 0,
    0, 0, 0, 1,
);

}

//绕y轴逆时针旋转30° { //three.js 旋转 m.makeRotationY(ang) console.log(...m.elements);

//手动旋转
const c = Math.cos(-ang)
const s = Math.sin(-ang)
console.log(
    c, 0, s, 0,
    0, 1, 0, 0,
    -s, 0, c, 0,
    0, 0, 0, 1,
);

}

//绕z轴逆时针旋转30° { //three.js 旋转 m.makeRotationZ(ang) console.log(...m.elements);

//手动旋转
const c = Math.cos(ang)
const s = Math.sin(ang)
console.log(
    c, s, 0, 0,
    -s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
);

}

扩展

大家要可以刻意锻炼一下自己的空间想象能力,在自己的识海里植入一个三维坐标系。

一般大家喜欢通过画图来推演三维算法,但那终究是二维的。

我们的眼睛决定了我们无法720° 无死角的观察三维场景,就像修真小说那样,放开神识,可以看见你身周方圆百里之内的一切事物。

不过,我们可以在自己的识海中搭建三维场景,你的识海越稳固,场景就可以越清晰、越复杂,这样比我们自己在纸上画图方便得多。

2-欧拉旋转

欧拉旋转就是绕单轴多次逆时针旋转,第一次是绕世界坐标系的单轴逆时针旋转,之后则是绕本地坐标系的单轴逆时针旋转。

2-1-示例

已知:

世界坐标系m1

点P 在世界坐标系内

点P 的世界坐标位P1(x,y,z)

求:

点P绕世界坐标系的x轴逆时针旋转angX度,

绕本地坐标系的y轴逆时针旋转angY度,

绕本地坐标系的z轴逆时针旋转angZ度后的世界位P2。

解:

分别基于angX,angY,angZ 建立三个矩阵mx,my,mz

点P的世界位是:

P2=mxmymz*P1

2-3-验证

我可以在three.js 里验证一下。

import { Group, Matrix4, Object3D, Scene, Vector3, Euler } from 'unpkg.com/three/build…';

const [angX, angY, angZ] = [1, 2, 3] const P1 = new Vector3(1, 1, 1)

//用矩阵乘法实现顶点绕单轴多次逆时针旋转 { const mx = new Matrix4().makeRotationX(angX) const my = new Matrix4().makeRotationY(angY) const mz = new Matrix4().makeRotationZ(angZ) //P2=mxmymz*P1 const P2 = P1.clone() P2.applyMatrix4(mx.multiply(my).multiply(mz)) console.log(P2); }

//用欧拉实现顶点绕单轴多次逆时针旋转 { const euler = new Euler(angX, angY, angZ) const m = new Matrix4() m.makeRotationFromEuler(euler) const P2 = P1.clone().applyMatrix4(m) console.log(P2); }

上面P2 的两个输出结果都是一样的。

2-4-讲个故事理解欧拉

通过之前的代码,大家可以发现欧拉旋转和咱们之前说过的世界坐标系、本地坐标系的呼应规律。

我们可以即此编一个关于王者荣耀故事:

宇宙,宇宙的本地坐标系是万物的世界坐标系,此坐标系为单位矩阵 mx:银河系的本地坐标系 my:太阳系的本地坐标系 mz:凡间界的本地坐标系 P1:瑶在欧拉旋转前的世界位 (瑶是王者荣耀里的角色) 宇宙∋银河系∋太阳系∋凡间界∋瑶

求:瑶欧拉旋转(angX,angY,angZ) 后的世界位P2,旋转顺序为xyz

解:

让瑶坠落凡间界。

当前宇宙万界的本地坐标系都是单位矩阵,所以瑶的世界坐标位P1,也是瑶在万界中的本地坐标位。

下面的P1也就可以理解为瑶在凡间界的本地坐标位。

const P1 = new Vector3(1, 1, 1)

将银河系、太阳系、凡间界分别沿x轴、y轴、z轴旋转angX、angY、angZ度。

const mx = new Matrix4().makeRotationX(angX) const my = new Matrix4().makeRotationY(angY) const mz = new Matrix4().makeRotationZ(angZ)

让瑶跳出三界之外,求其世界位

//P2=mxmymz*P1 const P2 = P1.clone() P2.applyMatrix4(mx.multiply(my).multiply(mz))

关于欧拉的旋转概念,我就先说到这,接下咱们再说一下四元数。

3-四元数

四元数Quaternion:让物体绕任意轴旋转a°。

我们对四元数的深度理解,也可以让我们脑海中的三维空间意识更加牢固。

我们通过一个例子来说明四元数旋转的实现过程。

已知:

轴OC2 弧度ang 点P1(x,y,z) const OC2 = new Vector3(3, 2, 1).normalize() const ang = 2 const P1 = new Vector3(1, 2, 3)

求:点P1绕OC2逆时针旋转ang度后的位置P2

解:

我接下来要把OC2转得与z轴同向。

计算绕x轴把OC2旋转到平面Ozx上的旋转矩阵mx1。

旋转的度数是OC2在平面Oyz上的正射影OB2与z轴的夹角,即∠B2OB1。

const B2OB1 = Math.atan2(OC2.y, OC2.z) const mx1 = new Matrix4().makeRotationX(B2OB1)

顺便再求出绕x轴反向旋转∠B2OB1的矩阵mx2,以备后用。 const mx2 = new Matrix4().makeRotationX(-B2OB1)

基于矩阵mx1旋转OC2,旋转到OC3的位置。 //OC3 = m1*OC2 const OC3 = OC2.clone() OC3.applyMatrix4(mx1)

计算绕y轴把OC3旋转到z轴上的旋转矩阵my1。

旋转的度数是OC3与z轴的夹角,即∠C3OB1。

const C3OB1 = Math.atan2(OC3.x, OC3.z) const my1 = new Matrix4().makeRotationY(-C3OB1)

​ 至于旋转后OC3在哪里,就不重要了,我们只要知道了其旋转了多少度,以及其最后会和z轴同向就够了。

顺便再求出绕y轴反向旋转∠C3OB1的矩阵my2,以备后用。 const my2 = new Matrix4().makeRotationY(C3OB1)

在OC2转到z轴上的时候,也让点P1做等量的旋转,得P2点 //P2 =my1mx1P1 const P2 = P1.clone() P2.applyMatrix4(mx1) P2.applyMatrix4(my1)

计算绕z轴旋转ang度的矩阵mz const mz = new Matrix4().makeRotationZ(ang)

让点P2绕z轴旋转ang 度 P2.applyMatrix4(mz)

让点P2按照之前OC2的旋转量再逆向转回去。 P2.applyMatrix4(my2) P2.applyMatrix4(mx2)

我们也可以把所有的矩阵合一下,再乘以P2

const P2 = P1.clone() const m = mx2.multiply(my2) .multiply(mz) .multiply(my1) .multiply(mx1) P2.applyMatrix4(m)

验证 const quaternion = new Quaternion(); quaternion.setFromAxisAngle(OC2, ang); const m = new Matrix4() m.makeRotationFromQuaternion(quaternion) console.log( P1.clone().applyMatrix4(m) );

总结一下四元数旋转的实现原理:

将旋转轴带着顶点一起旋转,让旋转轴与xyz中的某一个轴同向,比如z轴。

让顶点绕z轴旋转相应的度数。

让顶点按照之前旋转轴的旋转量逆向转回去。

注:

其实,四元数旋转的解法有很多种,比如我们还可以用复数来解四元数。

我上面所说的这种是我当前所知的最笨的,也是最通俗易懂的解四元数的方法。

我这里的主要目的就是先让大家把原理搞懂。

至于更加快捷、炫酷的解四元数的方法,等我讲完整个课程,再给大家补充。

第四章 正交投影矩阵

WebGL 是一个光栅引擎,其本身并不会实现三维效果,那我们要在其中实现三维效果的关键就在于算法:

顶点在裁剪空间中的位置=投影矩阵视图矩阵模型矩阵*顶点的初始点位

正交投影矩阵是投影矩阵的一种,我们先从它说起。

在说正交投影矩阵之前,我们还需要对裁剪空间有一个清晰的认知。

1-裁剪空间

裁剪空间是用于显示webgl图形的空间,此空间是一个宽、高、深皆为2 的盒子。其坐标系的原点在canvas画布的中心,如下图:

裁剪空间中:

x轴上-1的位置对应canvas画布的左边界,1的位置对应canvas 画布的右边界 y轴上-1的位置对应canvas画布的下边界,1的位置对应canvas 画布的上边界 z轴上-1的位置朝向屏幕外部,1的位置朝向屏幕内部,如下图:

2-正交投影矩阵的实现原理

正交投影矩阵 orthographic projection:将世界坐标系中的一块矩形区域(正交相机的可视区域)投射到裁剪空间中,不同深度的物体不具备近大远小的透视规则。

请问:要将一个任意尺寸的长方体塞进裁剪空间里,分几步?

答:先位移,再缩放

设:正交相机可视区域的上、下、左、右、前、后的边界分别是t、b、l、r、n、f

1.位移矩阵

[ 1,0,0,-(r+l)/2, 0,1,0,-(t+b)/2, 0,0,1,-(f+n)/2, 0,0,0,1, ]

2.缩放矩阵

[ 2/(r-l), 0, 0, 0, 0, 2/(t-b), 0, 0, 0, 0, 2/(f-n), 0, 0, 0, 0, 1, ]

正交投影矩阵=缩放矩阵*位移矩阵

[ 2/(r-l), 0, 0, -(r+l)/(r-l), 0, 2/(t-b), 0, -(t+b)/(t-b), 0, 0, 2/(f-n), -(f+n)/(f-n), 0, 0, 0, 1, ]

若n、f是一个距离量,而不是在z轴上的刻度值,正交投影矩阵在z轴上的缩放因子需要取反:

[ 2/(r-l), 0, 0, -(r+l)/(r-l), 0, 2/(t-b), 0, -(t+b)/(t-b), 0, 0, -2/(f-n), -(f+n)/(f-n), 0, 0, 0, 1, ]

3-正交投影矩阵的代码实现

正交投影矩阵的代码实现很简单,我们可以直接从three.js 的Matrix4对象的makeOrthographic() 方法中找到:

makeOrthographic( left, right, top, bottom, near, far ) {

const te = this.elements;
const w = 1.0 / ( right - left );
const h = 1.0 / ( top - bottom );
const p = 1.0 / ( far - near );

const x = ( right + left ) * w;
const y = ( top + bottom ) * h;
const z = ( far + near ) * p;

te[ 0 ] = 2 * w;    te[ 4 ] = 0;    te[ 8 ] = 0;    te[ 12 ] = - x;
te[ 1 ] = 0;    te[ 5 ] = 2 * h;    te[ 9 ] = 0;    te[ 13 ] = - y;
te[ 2 ] = 0;    te[ 6 ] = 0;    te[ 10 ] = - 2 * p;    te[ 14 ] = - z;
te[ 3 ] = 0;    te[ 7 ] = 0;    te[ 11 ] = 0;    te[ 15 ] = 1;

return this;

}

以前我们在绘制webgl 图形的时候,它们会随canvas 画布的大小发生拉伸,对于这个问题,我们便可以用投影矩阵来解决。

4-使用正交投影矩阵解决webgl图形拉伸问题

我们先准备一个三角形。

1.顶点着色器

u_ProjectionMatrix 正交投影矩阵

2.片元着色器。

3.绘制1个三角形

效果如下:

默认情况下,webgl 图形会被canvas 画布所拉伸。

我们可以通过对相机上下左右边界的设置,使其不被canvas 画布所拉伸。

4.定义相机世界高度尺寸的一半

const halfH = 2

5.计算画布的宽高比

const ratio = canvas.width / canvas.height

6.基于halfH和画布宽高比计算相机世界宽度尺寸的一半

const halfW = halfH * ratio

7.定义相机世界的6个边界

const [left, right, top, bottom, near, far] = [ -halfW, halfW, halfH, -halfH, 0, 4 ]

8.获取正交投影矩阵

projectionMatrix.makeOrthographic( left, right, top, bottom, near, far )

我们利用投影矩阵将现实世界投射到裁剪空间中后,往往还会对裁剪空间中视图进行位移或旋转,这时候就需要视图矩阵了。

第五章 视图矩阵

我们之前在说视图变换的时候说过视图矩阵,咱这里就通过three.js里的正交相机对象,更加形象的认识一下视图矩阵。

1-视图位移

1.基于之前的代码,再绘制一个三角形

const triangle1 = crtTriangle( [1, 0, 0, 1], [ 0, 0.3, -0.2, - 0.3, -0.3, -0.2, 0.3, -0.3, -0.2 ] )

const triangle2 = crtTriangle( [1, 1, 0, 1], [ 0, 0.3, 0.2, -0.3, -0.3, 0.2, 0.3, -0.3, 0.2, ] )

render()

function render() { gl.clear(gl.COLOR_BUFFER_BIT);

triangle1.init()
triangle1.draw()

triangle2.init()
triangle2.draw()

}

function crtTriangle(color, source) { return new Poly({ gl, source: new Float32Array(source), type: 'TRIANGLES', attributes: { a_Position: { size: 3, index: 0 }, }, uniforms: { u_Color: { type: 'uniform4fv', value: color }, u_ProjectionMatrix: { type: 'uniformMatrix4fv', value: projectionMatrix.elements }, } }) }

这是一前一后两个三角形。

前面的是黄色三角形,深度为0.2;

后面的是红色三角形,深度为-0.2,被前面的三角形挡住了,所以看不见。

效果如下:

2.从three.js里引入正交相机对象OrthographicCamera

import { Matrix4, Vector3,OrthographicCamera } from 'unpkg.com/three/build…';

3.建立正交相机对象

const camera = new OrthographicCamera(left, right, top, bottom, near, far)

4.设置相机位置position

camera.position.set(1, 1, 3) camera.updateWorldMatrix(true)

设置完相机位置后,要使用updateWorldMatrix() 方法更新相机的世界坐标系。

updateWorldMatrix() 方法主要是考虑到了相机存在父级的情况。

updateWorldMatrix() 方法会把更新后的世界坐标系写进写进相机的matrixWorld 属性里。

我们可以打印一下看看:

console.log(camera.matrixWorld.elements);

1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 3, 1

5.将相机的投影矩阵和相机的世界坐标系的逆矩阵合一下,合一个投影视图矩阵。

const pvMatrix = new Matrix4() pvMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse )

a.multiplyMatrices(b,c) 相当于: a=b*c

camera.projectionMatrix 可以直接获取相机的投影矩阵

matrixWorldInverse 是matrixWorld的逆矩阵,这是因为相机的移动方向和现实中的物体相反。

打印一下:

console.log(camera.matrixWorldInverse);

1 0 0 0 0 1 0 0 0 0 1 0 -1 -1 -3 1

7.把之前的projectionMatrix改成pvMatrix

顶点着色器

js 代码 function crtTriangle(color, source) { return new Poly({ gl, source: new Float32Array(source), type: 'TRIANGLES', attributes: { a_Position: { size: 3, index: 0 }, }, uniforms: { u_Color: { type: 'uniform4fv', value: color }, u_PvMatrix: { type: 'uniformMatrix4fv', value: pvMatrix.elements }, } }) }

扩展-matrixWorld详解

拿个例子说事。

已知:

宇宙universe

本地坐标系是m1 m1也是宇宙万界的世界坐标系

银河系 galaxy

本地坐标系是m2

太阳系 solar

本地坐标系是m3

太阳系∈银河系∈宇宙

求:太阳系的世界坐标系matrixWorld

解:

matrixWorld=m1m2m3

答案就这么简单,我们拿代码测一下:

//宇宙(世界坐标系是宇宙的本地坐标系) const universe = new Scene() universe.applyMatrix4(m1)

//银河系 const galaxy = new Group() galaxy.applyMatrix4(m2)

//太阳系 const solar = new Group() solar.applyMatrix4(m3)

//地球 const earth = new Object3D() earth.position.copy(P3)

//包含关系 solar.add(earth) galaxy.add(solar) universe.add(galaxy)

// 更新太阳系的世界坐标系 solar.updateWorldMatrix(true)

//太阳系的世界坐标系 console.log(...solar.matrixWorld.elements);

//手动计算太阳系的世界坐标系 console.log( ...m1.multiply(m2).multiply(m3).elements );

我现在是在把three.js里的核心知识分解到webgl里给大家详细讲解。

这样既可以为大家以后搭建自己的三维渲染引擎打下基础,也可以让大家真正的把three.js 当成工具来用。

之前有的小伙伴总想着直接去学习three.js,然后快速开发项目。

然而,这种这种想法对于开发简单的小项目还好,一遇到复杂些的图形项目,那就会举步维艰。

所以,既然决定要走图形可视化这条路,就一定要夯实基础。

扩展-逆矩阵

我之前在说matrixWorldInverse 的时候说过,它是matrixWorld 的逆矩阵。

逆矩阵在图形项目的应用很广,所以咱们接下来就系统说一下逆矩阵的概念。

1.逆矩阵的概念

逆矩阵就好比咱们学习除法的时候,一个实数的倒数。

如:

2的倒数是1/2。

那么,矩阵m的倒数就是1/m。

只不过,1/m不叫做矩阵m的倒数,而是叫做矩阵m的逆矩阵。

由上,我们可以推导出的一些特性。

已知:

矩阵m 矩阵n

可得:

1.矩阵与其逆矩阵的相乘结果为单位矩阵

因为:

2*1/2=1

所以:

m*1/m=单位矩阵

2.矩阵m除以矩阵n就等于矩阵m乘以矩阵n的逆矩阵

因为:

3/2=3*1/2

所以:

m/n=m*1/n

2.矩阵转逆矩阵

对于矩阵转逆矩阵的方法,我不说复杂了,就举几个简单例子给大家理解其原理。

位移矩阵的逆矩阵是取位移因子的相反数 const m=new Matrix4() m.elements=[ 1,0,0,0, 0,1,0,0, 0,0,1,0, 4,5,6,1, ] console.log(m.invert().elements); //打印结果 [ 1,0,0,0, 0,1,0,0, 0,0,1,0, -4,-5,-6,1, ]

缩放矩阵的逆矩阵是取缩放因子的倒数 { const m=new Matrix4() m.elements=[ 2,0,0,0, 0,4,0,0, 0,0,8,0, 0,0,0,1, ] console.log(m.invert().elements); } //打印结果 [ 0.5, 0, 0, 0, 0, 0.25, 0, 0, 0, 0, 0.125, 0, 0, 0, 0, 1 ]

3.旋转矩阵的逆矩阵是基于旋转弧度反向旋转

{ const ang=30*Math.PI/180 const c=Math.cos(ang) const s=Math.sin(ang) const m=new Matrix4() m.elements=[ c,s,0,0, -s,c,0,0, 0,0,1,0, 0,0,0,1, ] console.log(m.invert().elements); } //打印结果 [ 0.866, -0.45, 0, 0, 0.45, 0.866, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]

关于即旋转又缩放还位移的复合矩阵,也是按照类似的原理转逆矩阵的,只不过过程要更复杂一些。

复合矩阵转逆矩阵的方法我就先不说了,等走完整个课程我再给你大家详解。

若有同学对其感兴趣,可以先自己看一下three.js的Matrix4对象的invert() 方法。

invert() { // based on www.euclideanspace.com/maths/algeb… const te = this.elements,

    n11 = te[ 0 ], n21 = te[ 1 ], n31 = te[ 2 ], n41 = te[ 3 ],
    n12 = te[ 4 ], n22 = te[ 5 ], n32 = te[ 6 ], n42 = te[ 7 ],
    n13 = te[ 8 ], n23 = te[ 9 ], n33 = te[ 10 ], n43 = te[ 11 ],
    n14 = te[ 12 ], n24 = te[ 13 ], n34 = te[ 14 ], n44 = te[ 15 ],

    t11 = n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44,
    t12 = n14 * n33 * n42 - n13 * n34 * n42 - n14 * n32 * n43 + n12 * n34 * n43 + n13 * n32 * n44 - n12 * n33 * n44,
    t13 = n13 * n24 * n42 - n14 * n23 * n42 + n14 * n22 * n43 - n12 * n24 * n43 - n13 * n22 * n44 + n12 * n23 * n44,
    t14 = n14 * n23 * n32 - n13 * n24 * n32 - n14 * n22 * n33 + n12 * n24 * n33 + n13 * n22 * n34 - n12 * n23 * n34;

const det = n11 * t11 + n21 * t12 + n31 * t13 + n41 * t14;

if ( det === 0 ) return this.set( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 );

const detInv = 1 / det;

te[ 0 ] = t11 * detInv; te[ 1 ] = ( n24 * n33 * n41 - n23 * n34 * n41 - n24 * n31 * n43 + n21 * n34 * n43 + n23 * n31 * n44 - n21 * n33 * n44 ) * detInv; te[ 2 ] = ( n22 * n34 * n41 - n24 * n32 * n41 + n24 * n31 * n42 - n21 * n34 * n42 - n22 * n31 * n44 + n21 * n32 * n44 ) * detInv; te[ 3 ] = ( n23 * n32 * n41 - n22 * n33 * n41 - n23 * n31 * n42 + n21 * n33 * n42 + n22 * n31 * n43 - n21 * n32 * n43 ) * detInv;

te[ 4 ] = t12 * detInv; te[ 5 ] = ( n13 * n34 * n41 - n14 * n33 * n41 + n14 * n31 * n43 - n11 * n34 * n43 - n13 * n31 * n44 + n11 * n33 * n44 ) * detInv; te[ 6 ] = ( n14 * n32 * n41 - n12 * n34 * n41 - n14 * n31 * n42 + n11 * n34 * n42 + n12 * n31 * n44 - n11 * n32 * n44 ) * detInv; te[ 7 ] = ( n12 * n33 * n41 - n13 * n32 * n41 + n13 * n31 * n42 - n11 * n33 * n42 - n12 * n31 * n43 + n11 * n32 * n43 ) * detInv;

te[ 8 ] = t13 * detInv; te[ 9 ] = ( n14 * n23 * n41 - n13 * n24 * n41 - n14 * n21 * n43 + n11 * n24 * n43 + n13 * n21 * n44 - n11 * n23 * n44 ) * detInv; te[ 10 ] = ( n12 * n24 * n41 - n14 * n22 * n41 + n14 * n21 * n42 - n11 * n24 * n42 - n12 * n21 * n44 + n11 * n22 * n44 ) * detInv; te[ 11 ] = ( n13 * n22 * n41 - n12 * n23 * n41 - n13 * n21 * n42 + n11 * n23 * n42 + n12 * n21 * n43 - n11 * n22 * n43 ) * detInv;

te[ 12 ] = t14 * detInv; te[ 13 ] = ( n13 * n24 * n31 - n14 * n23 * n31 + n14 * n21 * n33 - n11 * n24 * n33 - n13 * n21 * n34 + n11 * n23 * n34 ) * detInv; te[ 14 ] = ( n14 * n22 * n31 - n12 * n24 * n31 - n14 * n21 * n32 + n11 * n24 * n32 + n12 * n21 * n34 - n11 * n22 * n34 ) * detInv; te[ 15 ] = ( n12 * n23 * n31 - n13 * n22 * n31 + n13 * n21 * n32 - n11 * n23 * n32 - n12 * n21 * n33 + n11 * n22 * n33 ) * detInv;

return this;

}

2-视图旋转

我们之前实现了视图的移动效果,然而有时候当我们遇到一个好玩的物体时,需要在不移动相机的前提下看向它。

这个时候,我们就需要旋转视图了。

2-1-用lookAt()实现视图旋转

接下来,我还是站在three.js 这个巨人的肩膀上,用它的lookAt()方法实现视图旋转。

已知:

正交相机的边界 left, right, top, bottom, near, far 正交相机的视点位置 eye 正交相机的目标点 target 正交相机从eye看向target时的上方向up

求:从视点看向目标点时的投影视图矩阵 pvMatrix

解:

1.声明已知条件

const halfH = 2 const ratio = canvas.width / canvas.height const halfW = halfH * ratio const [left, right, top, bottom, near, far] = [ -halfW, halfW, halfH, -halfH, 0, 4 ] const eye = new Vector3(1, 1, 3) const target = new Vector3(0, 0, 0) const up = new Vector3(0, 1, 0)

2.建立正交相机

const camera = new OrthographicCamera( left, right, top, bottom, near, far )

3.设置相机的位置

camera.position.copy(eye)

4.使用lookAt()方法,让相机看向目标点,并更新一下相机的世界坐标系。

camera.lookAt(target) camera.updateWorldMatrix(true)

上面的lookAt() 方法实际上就是在让相机世界进行旋转。

之后,现实世界在裁剪空间中显示的时候,便会基于此旋转量逆向旋转。

5.通过相机计算投影视图矩阵 pvMatrix

const pvMatrix = new Matrix4() pvMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse, )

效果如下:

接下来,我们对lookAt 功能进行一下深度剖析。

2-2-深度剖析lookAt功能

我们先不考虑相机存在父级情况。

我们可以从之前的正交相机里分解出以下矩阵:

视图矩阵viewMatrix:相机位移矩阵乘以旋转矩阵后的逆矩阵,即相机的世界矩阵的逆矩阵 位移矩阵positionMatrix:由视点位置得出 旋转矩阵rotationMatrix:由视点、目标点、上方向得出 投影矩阵projectionMatrix:由正交相机的6个边界得出 投影视图矩阵:投影矩阵乘以视图矩阵

接下来咱们就基于之前的代码做一下分解:

1.由视点位置得出位移矩阵positionMatrix

const positionMatrix = new Matrix4().setPosition(eye)

2.由视点、目标点、上方向得出旋转矩阵rotationMatrix

const rotationMatrix = new Matrix4().lookAt(eye,target,up)

3.基于位移矩阵和旋转矩阵计算视图矩阵 viewMatrix

const viewMatrix = new Matrix4().multiplyMatrices( positionMatrix, rotationMatrix ).invert()

4.由正交相机对象提取投影矩阵 projectionMatrix

const camera = new OrthographicCamera( left, right, top, bottom, near, far ) const projectionMatrix = camera.projectionMatrix

5.由投影矩阵和视图矩阵的相乘得到投影视图矩阵 pvMatrix

const pvMatrix = new Matrix4().multiplyMatrices( projectionMatrix, viewMatrix )

6.最后在顶点着色器里让pvMatrix乘以顶点点位即可

attribute vec4 a_Position; uniform mat4 u_PvMatrix; void main(){ gl_Position = u_PvMatrix*a_Position; }

注:若相机对象存在父级,就需要基于相机的世界坐标系做相应运算了。

第六章 透视投影矩阵

透视投影矩阵可以将现实世界更真实的投射到裁剪空间中。

我们的肉眼看现实世界时,用的就是透视投影矩阵。

透视投影矩阵包含了许多的基础图形知识,这些知识在其它地方都是能单独用得上的。

透视投影矩阵是一个专业图形程序员所必备的,只有对其有了透彻的理解,我们才能有底气将薪资要到30K、或者40K之上。

接下来,为了让大家学起来更加流畅,咱们先强调点基础知识。

1-基础知识

为了让大家学起来更加丝滑,我再跟大家强调两个知识点。

1-1-齐次坐标系

在齐次坐标系中以下等式是成立的:

(x,y,z,1)=(x,y,z,1)*k=(kx,ky,kz,k) k≠0 (x,y,z,1)=(x,y,z,1)*z=(zx,zy,z²,z) z≠0

比如:

(1,0,0,1)和(2,0,0,2) 都代表同一个三维点位(1,0,0)

1-2-线性补间运算

之前我们说过点斜式y=kx+b,它就是线性补间运算的公式。

除了点斜式,两种数据间的线性映射关系还可以用其它方法来表示。

已知:

N类型的数据极值是[minN,maxN]

M类型的数据极值是[minM,maxM]

x属于N

将x映射到M的中的值为y

则x,y 的关系可以用两个等式表示:

比例式: (x-minN)/(maxN-minN)=(y-minM)/(maxM-minM)

点斜式 k=(maxM-minM)/(maxN-minN) b=minM-minN*k y=kx+b

通过线性插值的特性,我们可以知道:

[minN,maxN]中的每个点都与[minM,maxM]中的唯一点相对应,由一个x便可以求出唯一一个y。

基础知识咱们就先说到这,接下来咱们认识一下透视投影矩阵。

2-认识透视投影矩阵

透视投影矩阵 perspective projection:将世界坐标系中的一块四棱台形的区域投射到裁剪空间中,不同深度的物体具备近大远小的透视规则。

透视相机的建立需要以下已知条件:

fov:摄像机视锥体垂直视野角度 aspect:摄像机视锥体宽高比 near:摄像机近裁剪面到视点的距离 far:摄像机远裁剪面到视点的距离

请问:要将一个任意尺寸的正四棱台塞进裁剪空间里,分几步?

答:从透视到正交。

1.收缩远裁剪面,将原来的正四棱台变成长方体。

2.像之前的正交投影矩阵一样,将长方体先位移,再缩放。

接下来咱们就去计算一下透视投影矩阵。

3-计算透视投影矩阵

1.基于fov、aspect、n(near)、f(far)计算近裁剪面边界。

t=ntan(fov/2) b=-t r=taspect l=-r

2.设:可视区域中一顶点为P1(x1,y1,z1)

​ 求:求P1在近裁剪面上的投影P2(x2,y2,z2)

由相似三角形性质得:

x1/x2=y1/y2=z1/z2

因为:

z2=-n

所以:

x2=nx1/-z1 y2=ny1/-z1

若我们把P1点的x1,y1替换成x2,y2,就可以理解为把相机可视区域塞进了一个长方体里。

3.把长方体里的顶点塞进裁剪空间中。

设:P2映射到裁剪空间中的点为P3(x3,y3,z3) 点

则:P2点和P3点满足以下关系式:

x方向 (x3-(-1))/(1-(-1))=(x2-l)/(r-l) (x3+1)/2=(x2-l)/(r-l) (x3+1)=2(x2-l)/(r-l) x3=2(x2-l)/(r-l)-1 x3=2(x2-l)/(r-l)-(r-l)/(r-l) x3=(2(x2-l)-(r-l))/(r-l) x3=(2x2-(r+l))/(r-l) x3=2x2/(r-l)-(r+l)/(r-l)

因为:

x2=nx1/-z1

所以:

x3=2(nx1/-z1)/(r-l)-(r+l)/(r-l) x3=(2n/(r-l))x1/-z1-(r+l)/(r-l)

y方向 (y3-(-1))/(1-(-1))=(y2-b)/(t-b) y3=(2n/(t-b))y1/-z1-(t+b)/(t-b)

观察一下当前求出的x3,y3:

x3=(2n/(r-l))x1/-z1-(r+l)/(r-l) y3=(2n/(t-b))y1/-z1-(t+b)/(t-b)

只要让x3,y3 乘以-z1,便可以得到一个齐次坐标P4(x4,y4,z4,w4):

x4=(2n/(r-l))x1+((r+l)/(r-l))z1 y4=(2n/(t-b))y1+((t+b)/(t-b))z1 z4=? w4=-z1

当前把顶点的z分量投射到裁剪空间中的方法,我们还不知道,所以z4=?

我们可以先从已知条件中提取投影矩阵(行主序)的矩阵因子:

[ 2n/(r-l) 0 (r+l)/(r-l) 0, 0 2n/(t-b) (t+b)/(t-b) 0, ? ? ? ?, 0 0 -1 0 ]

接下来,就剩下z轴相关的矩阵因子了。

因为整个投影矩阵始终是在做线性变换的,投影点的z值与投影矩阵的z轴向的x,y分量无关。

所以投影矩阵的z轴向的x,y分量可以写做0,z和w分量可以设为k,b,如下:

[ 2n/(r-l) 0 (r+l)/(r-l) 0, 0 2n/(t-b) (t+b)/(t-b) 0, 0 0 k b 0 0 -1 0 ]

之前说了,整个投影矩阵始终是在做线性变换,所以我们可以用k,b组合一个点斜式:

z4=k*z1+b

当然,你也可以认为是点积的结果:

z4=(0,0,k,b)·(x1,y1,z1,1) z4=k*z1+b

接下来,我们只要求出上面的k,b,就可以得到透视投影矩阵。

我们可以用当前的已知条件,构建一个二元一次方程组,求出k,b:

当z1=-n 时,z3=-1,z4=-1*-z1 ,即: z4=kz1+b -1n=k*-n+b -n=-kn+b b=kn-n

当z1=-f 时,z3=1,z4=1*-z1,即: z4=kz1+b 1f=k*-f+b f=-kf+b kf=b-f k=(b-f)/f

用消元法求b:

b=kn-n b=((b-f)/f)n-n b=(b-f)n/f-n fb=(b-f)n-fn fb=bn-fn-fn fb-bn=-2fn b(f-n)=-2fn b=-2fn/(f-n)

再求k:

k=(b-f)/f k=(-2fn/(f-n)-f)/f k=-2n/(f-n)-1 k=(-2n-f+n)/(f-n) k=(-f-n)/(f-n) k=-(f+n)/(f-n)

最终的透视投影矩阵如下:

[ 2n/(r-l) 0 (r+l)/(r-l) 0, 0 2n/(t-b) (t+b)/(t-b) 0, 0 0 -(f+n)/(f-n) -2fn/(f-n), 0 0 -1 0 ]

透视投影的建立方法,我们可以在three.js 的源码里找到。

4-three.js 里的透视投影矩阵

three.js 的PerspectiveCamera对象的updateProjectionMatrix() 方法,便是透视相机建立透视投影矩阵的方法。

updateProjectionMatrix() { const near = this.near; //近裁剪面上边界 let top = near * Math.tan( MathUtils.DEG2RAD * 0.5 * this.fov ) / this.zoom; //近裁剪面高度 let height = 2 * top; //近裁剪面宽度 let width = this.aspect * height; //近裁剪面左边界 let left = - 0.5 * width; //默认为null const view = this.view;

//多视图
if ( this.view !== null && this.view.enabled ) {
    const fullWidth = view.fullWidth,
          fullHeight = view.fullHeight;
    left += view.offsetX * width / fullWidth;
    top -= view.offsetY * height / fullHeight;
    width *= view.width / fullWidth;
    height *= view.height / fullHeight;

}
//偏离值,默认0
const skew = this.filmOffset;
if ( skew !== 0 ) left += near * skew / this.getFilmWidth();

//基于近裁剪面边界、近裁剪面和远裁剪面到相机视点的距离设置投影矩阵
this.projectionMatrix.makePerspective( left, left + width, top, top - height, near, this.far );

//投影矩阵的逆矩阵
this.projectionMatrixInverse.copy( this.projectionMatrix ).invert();

}

makePerspective() 是Matrix4对象里的方法,会基于投影空间建立透视投影矩阵 makePerspective( left, right, top, bottom, near, far ) { const te = this.elements;

const x = 2 * near / ( right - left );
const y = 2 * near / ( top - bottom );
const a = ( right + left ) / ( right - left );
const b = ( top + bottom ) / ( top - bottom );
const c = - ( far + near ) / ( far - near );
const d = - 2 * far * near / ( far - near );

te[ 0 ] = x;    te[ 4 ] = 0;    te[ 8 ] = a;    te[ 12 ] = 0;
te[ 1 ] = 0;    te[ 5 ] = y;    te[ 9 ] = b;    te[ 13 ] = 0;
te[ 2 ] = 0;    te[ 6 ] = 0;    te[ 10 ] = c;    te[ 14 ] = d;
te[ 3 ] = 0;    te[ 7 ] = 0;    te[ 11 ] = - 1;    te[ 15 ] = 0;

return this;

}

5-透视投影矩阵牛刀小试

我们用透视投影矩阵展示几个可以近大远小的三角形。

1.着色器

2.初始化着色器

import { initShaders } from '../jsm/Utils.js'; import { Matrix4,PerspectiveCamera, Vector3, Quaternion, Object3D, OrthographicCamera } from 'unpkg.com/three/build…'; import Poly from './jsm/Poly.js'

const canvas = document.getElementById('canvas'); const [viewW, viewH] = [window.innerWidth, window.innerHeight] canvas.width = viewW; canvas.height = viewH; const gl = canvas.getContext('webgl');

const vsSource = document.getElementById('vertexShader').innerText; const fsSource = document.getElementById('fragmentShader').innerText; initShaders(gl, vsSource, fsSource); gl.clearColor(0.0, 0.0, 0.0, 1.0);

3.建立透视相机

const [fov,aspect,near,far]=[ 45, canvas.width / canvas.height, 1, 20 ] const camera = new PerspectiveCamera(fov,aspect,near,far)

4.基于相机的透视投影矩阵,绘制4个三角形。

前面是两个黄色三角形,后面是两个红色三角形。

const triangle1 = crtTriangle( [1, 0, 0, 1], [-0.5,0,-3] )

const triangle2 = crtTriangle( [1, 0, 0, 1], [0.5,0,-3] )

const triangle3 = crtTriangle( [1, 1, 0, 1], [-0.5,0,-2] )

const triangle4 = crtTriangle( [1, 1, 0, 1], [0.5,0,-2] )

function crtTriangle(color, [x,y,z]) { return new Poly({ gl, source: [ x, 0.3+y, z, -0.3+x, -0.3+y, z, 0.3+x, -0.3+y, z, ], type: 'TRIANGLES', attributes: { a_Position: { size: 3, index: 0 }, }, uniforms: { u_Color: { type: 'uniform4fv', value: color }, u_ProjectionMatrix: { type: 'uniformMatrix4fv', value: camera.projectionMatrix.elements }, } }) }

gl.clear(gl.COLOR_BUFFER_BIT); render()

function render() { gl.clear(gl.COLOR_BUFFER_BIT);

triangle1.init()
triangle1.draw()

triangle2.init()
triangle2.draw()

triangle3.init()
triangle3.draw()

triangle4.init()
triangle4.draw()

}

实际项目中,只有投影矩阵是不够的,还要有视图矩阵和模型矩阵。

第七章 投影矩阵、视图矩阵、模型矩阵共冶一炉

投影矩阵、视图矩阵、模型矩阵的结合方式:

最终的顶点坐标=投影矩阵视图矩阵模型矩阵*初始顶点坐标

1-投影视图矩阵

1.在顶点着色器里把投影矩阵变成投影视图矩阵。

2.设置相机位置,并让其看向一点

const eye = new Vector3(0, 1, 1) const target = new Vector3(0, 0, -2.5) const up = new Vector3(0, 1, 0)

const [fov,aspect,near,far]=[ 45, canvas.width / canvas.height, 1, 20 ]

const camera = new PerspectiveCamera(fov,aspect,near,far) camera.position.copy(eye) camera.lookAt(target) camera.updateWorldMatrix(true)

3.计算投影视图矩阵,即让相机的投影矩阵乘以视图矩阵

const pvMatrix = new Matrix4() pvMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse, )

4.修改一下建立三角形方法里的uniform 变量

u_PvMatrix: { type: 'uniformMatrix4fv', value: pvMatrix.elements },

效果如下:

接下来,我们再把模型矩阵加进去。

2-投影视图矩阵乘以模型矩阵

之前我们设置三角形位置的时候,是直接对顶点的原始数据进行的修改。

source: [ x, 0.3 + y, z, -0.3 + x, -0.3 + y, z, 0.3 + x, -0.3 + y, z, ],

其实,我是可以将位移数据写进模型矩阵里的,当然旋转和缩放数据也可以写进去,然后用模型矩阵乘以原始顶点,从而实现对模型的变换。

1.顶点着色器

attribute vec4 a_Position; uniform mat4 u_PvMatrix; uniform mat4 u_ModelMatrix; void main(){ gl_Position = u_PvMatrixu_ModelMatrixa_Position; }

2.在crtTriangle()方法里,把三角形的数据源写死,在uniforms 里添加一个模型矩阵。

function crtTriangle(color, modelMatrix) { return new Poly({ gl, modelMatrix, source: [ 0, 0.3, 0, -0.3, -0.3, 0, 0.3, -0.3, 0, ], type: 'TRIANGLES', attributes: { a_Position: { size: 3, index: 0 }, }, uniforms: { u_Color: { type: 'uniform4fv', value: color }, u_PvMatrix: { type: 'uniformMatrix4fv', value: pvMatrix.elements }, u_ModelMatrix: { type: 'uniformMatrix4fv', value: modelMatrix }, } }) }

2.建立四个三角形

const triangle1 = crtTriangle( [1, 0, 0, 1], [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.5, 0, -3, 1, ] )

const triangle2 = crtTriangle( [1, 0, 0, 1], [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.5, 0, -3, 1, ] )

const triangle3 = crtTriangle( [1, 1, 0, 1], [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.5, 0, -2, 1, ] )

const triangle4 = crtTriangle( [1, 1, 0, 1], [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.5, 0, -2, 1, ] )

效果如下:

第八章 正交相机轨道控制器

相机轨道控制器可以让我们更好的变换相机,从而灵活观察物体。

three.js 中的相机轨道控制器是通过以下事件变换相机的:

旋转

鼠标左键拖拽 单手指移动

缩放

鼠标滚轮滚动 两个手指展开或挤压

平移

鼠标右键拖拽 鼠标左键+ctrl/meta/shiftKey 拖拽 箭头键 两个手指移动

在实际项目开发中,我们不能对three.js 里的相机轨道控制器太过依赖。

因为其不能满足我们图形项目里的所有需求,这是经验之谈,好多同学都遇到过这种情况。

面对这样的情况,我们若不能充分理解相机轨道控制器的实现原理,整个项目都会被卡住。

所以,我们接下来要从最底层实现相机轨道控制器。

我们先使用相机轨道控制器变换正交相机。

1-正交相机的位移轨道 1-1-搭建场景

准备4个三角形+1个相机

着色器

初始化着色器 import { initShaders } from '../jsm/Utils.js'; import { Matrix4, PerspectiveCamera, Vector2, Vector3, Quaternion, Object3D, OrthographicCamera } from 'unpkg.com/three/build…'; import Poly from './jsm/Poly.js'

const canvas = document.getElementById('canvas'); const [viewW, viewH] = [window.innerWidth, window.innerHeight] canvas.width = viewW; canvas.height = viewH; const gl = canvas.getContext('webgl');

const vsSource = document.getElementById('vertexShader').innerText; const fsSource = document.getElementById('fragmentShader').innerText; initShaders(gl, vsSource, fsSource); gl.clearColor(0.0, 0.0, 0.0, 1.0);

正交相机 const halfH = 2 const ratio = canvas.width / canvas.height const halfW = halfH * ratio const [left, right, top, bottom, near, far] = [ -halfW, halfW, halfH, -halfH, 1, 8 ] const eye = new Vector3(1, 1, 2) const target = new Vector3(0, 0, -3) const up = new Vector3(0, 1, 0)

const camera = new OrthographicCamera( left, right, top, bottom, near, far ) camera.position.copy(eye) camera.lookAt(target) camera.updateMatrixWorld() const pvMatrix = new Matrix4() pvMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse, )

4个三角形 const triangle1 = crtTriangle( [1, 0, 0, 1], new Matrix4().setPosition(-0.5, 0, -4).elements ) const triangle2 = crtTriangle( [1, 0, 0, 1], new Matrix4().setPosition(0.5, 0, -4).elements ) const triangle3 = crtTriangle( [1, 1, 0, 1], new Matrix4().setPosition(-0.8, 0, -2).elements ) const triangle4 = crtTriangle( [1, 1, 0, 1], new Matrix4().setPosition(0.5, 0, -2).elements )

render() function render() { gl.clear(gl.COLOR_BUFFER_BIT);

triangle1.init()
triangle1.draw()

triangle2.init()
triangle2.draw()

triangle3.init()
triangle3.draw()

triangle4.init()
triangle4.draw()

}

1-2-声明基础数据 鼠标事件集合 const mouseButtons = new Map([ [2, 'pan'] ])

​ 2:鼠标右键按下时的event.button值

​ pan:平移

轨道控制器状态,表示控制器正在对相机进行哪种变换。比如state等于pan 时,代表位移 let state = 'none'

鼠标在屏幕上拖拽时的起始位和结束位,以像素为单位 const dragStart = new Vector2() const dragEnd = new Vector2()

鼠标每次移动时的位移量,webgl坐标量 const panOffset = new Vector3()

鼠标在屏幕上垂直拖拽时,是基于相机本地坐标系的y方向还是z方向移动相机 true:y向移动 false:z向移动 const screenSpacePanning = true

1-3-在canvas上绑定鼠标事件 取消右击菜单的显示 canvas.addEventListener('contextmenu', event => { event.preventDefault() })

指针按下时,设置拖拽起始位,获取轨道控制器状态。 canvas.addEventListener('pointerdown', ({ clientX, clientY, button }) => { dragStart.set(clientX, clientY) state = mouseButtons.get(button) })

注:指针事件支持多种方式的指针顶点输入,如鼠标、触控笔、触摸屏等。

指针移动时,若控制器处于平移状态,平移相机。 canvas.addEventListener('pointermove', (event) => { switch (state) { case 'pan': handleMouseMovePan(event) } })

指针抬起时,清除控制器状态。 canvas.addEventListener('pointerup', (event) => { state = 'none' })

接下来我们重点看一下相机平移方法handleMouseMovePan()。

1-4-相机平移方法

相机平移方法

function handleMouseMovePan({ clientX, clientY, button }) { //指针拖拽的结束位(像素单位) dragEnd.set(clientX, clientY) //基于拖拽距离(像素单位)移动相机 pan(dragEnd.clone().sub(dragStart)) //重置拖拽起始位 dragStart.copy(dragEnd) }

基于拖拽距离(像素单位)移动相机

function pan(delta) { //相机近裁剪面尺寸 const cameraW = camera.right - camera.left const cameraH = camera.top - camera.bottom //指针拖拽量在画布中的比值 const ratioX = delta.x / canvas.clientWidth const ratioY = delta.y / canvas.clientHeight //将像素单位的位移量转换为相机近裁剪面上的位移量 const distanceLeft = ratioX * cameraW const distanceUp = ratioY * cameraH //相机本地坐标系里的x轴 const mx = new Vector3().setFromMatrixColumn(camera.matrix, 0) //相机x轴平移量 const vx = mx.clone().multiplyScalar(-distanceLeft) //相机z|y轴平移量 const vy = new Vector3() if (screenSpacePanning) { //y向 vy.setFromMatrixColumn(camera.matrix, 1) } else { //-z向 vy.crossVectors(camera.up, mx) } //相机y向或-z向的平移量 vy.multiplyScalar(distanceUp) //整合平移量 panOffset.copy(vx.add(vy)) //更新 update() }

基于平移量,位移相机,更新投影视图矩阵 function update() { target.add(panOffset) camera.position.add(panOffset) camera.lookAt(target) camera.updateWorldMatrix(true) pvMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse, ) render() }

2-正交相机的缩放轨道 2-1-正交相机的缩放原理

相机的缩放就是让我们在裁剪空间中看到的同一深度上的东西更多或者更少。

通常大家很容易结合实际生活来考虑,比如我们正对着一面墙壁,墙壁上铺满瓷砖。

当我们把镜头拉近时,看到的瓷砖数量就变少了,每块瓷砖的尺寸也变大了;

反之,当我们把镜头拉远时,看到的瓷砖数量就变多了,每块瓷砖的尺寸也变小了。

然而这种方式只适用于透视相机,并不适用于正交相机,因为正交相机不具备近大远小规则。

正交相机的缩放,是直接缩放的投影面,这个投影面在three.js里就是近裁剪面。

当投影面变大了,那么能投影的顶点数量也就变多了;

反之,当投影面变小了,那么能投影的顶点数量也就变少了。

接下来,我们去分析一下相机的具体缩放方法。

2-2-正交相机缩放方法

在three.js里的正交相机对象OrthographicCamera的updateProjectionMatrix() 方法里可以找到正交相机的缩放方法。

updateProjectionMatrix: function () { const dx = ( this.right - this.left ) / ( 2 * this.zoom ); const dy = ( this.top - this.bottom ) / ( 2 * this.zoom ); const cx = ( this.right + this.left ) / 2; const cy = ( this.top + this.bottom ) / 2;

let left = cx - dx;
let right = cx + dx;
let top = cy + dy;
let bottom = cy - dy;
……

}

我们可以将上面的dx、dy分解一下:

近裁剪面宽度的一半width:( this.right - this.left ) / 2 近裁剪面高度的一半height:( this.top - this.bottom ) / 2 dx=width/zoom dy=height/zoom

在three.js 里,zoom 的默认值是1,即不做缩放。

由上我们可以得到正交相机缩放的性质:

zoom值和近裁剪面的尺寸成反比 近裁剪面的尺寸和我们在同一深度所看物体的数量成正比 近裁剪面的尺寸和我们所看的同一物体的尺寸成反比 2-4-正交相机缩放轨道的实现

基于之前的相机位移轨道继续写代码。

1.定义滚轮在每次滚动时的缩放系数

const zoomScale = 0.95

2.为canvas添加滚轮事件

canvas.addEventListener('wheel', handleMouseWheel)

function handleMouseWheel({ deltaY }) { if (deltaY < 0) { dolly(1 / zoomScale); } else if (deltaY > 0) { dolly(zoomScale); } update(); }

当 deltaY<0 时,是向上滑动滚轮,会缩小裁剪面;

当 deltaY>0 时,是向下滑动滚轮,会放大裁剪面。

3.通过dolly()方法缩放相机

function dolly(dollyScale) { camera.zoom *= dollyScale camera.updateProjectionMatrix(); }

3-正交相机的旋转轨道 3-1-正交相机的旋转轨道的概念

相机的旋转轨道的实现原理就是让相机绕物体旋转。

相机旋转轨迹的集合是一个球体。

相机旋转轨道的实现方式是有许多种的,至于具体用哪种,还要看我们具体的项目需求。

我们这里就先说一种基于球坐标系旋转的相机旋转轨道,至于其它的旋转方式,我们后面再说。

已知:

三维坐标系[O;x,y,z] 正交相机 视点位:点P 目标位:点O 正交相机旋转轨的旋转轴是y轴

则:

正交相机在球坐标系中的旋转轨道有两种:

点P绕旋转轴y轴的旋转轨道,即上图的蓝色轨道。 点P在平面OPy中的旋转轨道,即上图的绿色轨迹。

接下来,结合正交相机的实际情况,说一下如何计算正交相机旋转后的视点位。

3-2-正交相机旋转后的视点位

已知:

三维坐标系[O;x,y,z] 正交相机 视点位:三维坐标点P(x,y,z) 目标位:点O 正交相机旋转轨的旋转轴是y轴

求:相机在平面OPy中旋转a度,绕y轴旋转b度后,相机视点的三维空间位P'(x',y',z')

解:

1.将点P(x,y,z)的三维坐标位换算为球坐标位,即P(r,φ,θ)

2.计算点P在平面OPy中旋转a度,绕y轴旋转b度后的球坐标位,即P(r,φ+a,θ+b)

3.将点P的球坐标位转换为三维坐标位

求解的思路就这么简单,那具体怎么实现呢?咱们先把上面的球坐标解释一下。

3-3-球坐标系

Ⅰ 球坐标系的概念

球坐标系(spherical coordinate system)是用球坐标表示空间点位的坐标系。

球坐标由以下分量构成:

半径(radial distance) r:OP长度( 0 ≤ r ) 。 极角(polar angle) φ:OP与y轴的夹角(0 ≤ φ ≤ π) 方位角(azimuth angle) θ:OP在平面Oxz上的投影与正x轴的夹角( 0 ≤ θ < 2π )。

注:

球坐标系可视极坐标系的三维推广。 当r=0时,φ和θ无意义。 当φ =0或φ =π时,θ无意义。

接下来咱们说一下球坐标与三维坐标的转换。

Ⅱ 三维坐标转球坐标

已知:点P的三维坐标位(x,y,z)

求:点P的球坐标位(r,φ,θ)

解:

求半径r:

r=sqrt(x²+y²+z²)

求极角φ的方法有三种:

φ=acos(y/r) φ=asin(sqrt(x²+z²)/r) φ=atan(sqrt(x²+z²)/y)

求方位角θ的方法有三种:

θ=acos(x/(rsinφ)) θ=asin(z/(rsinφ)) θ=atan(z/x)

注:

在用反正切求角度时,需要注意点问题。

atan()返回的值域是[-PI/2,PI/2],这是个半圆,这会导致其返回的弧度失真。

如:

atan(z/x)==atan(-z/-x) atan(-z/x)==atan(z/-x)

所以,我们在js里用反正切计算弧度时,要使用atan2() 方法,即:

φ=Math.atan2(sqrt(x²+z²),y) θ=Math.atan2(z,x)

atan2()返回的值域是[-PI,PI],这是一个整圆。

atan2()方法是将z,x分开写入的,其保留了其最原始的正负符号,所以其返回的弧度不会失真。

Ⅲ 球坐标转三维坐标

已知:点P的球坐标位(r,φ,θ)

求:点P的三维坐标位(x,y,z)

解:

x=rsinφcosθ y=rcosφ z=rsinφ*sinθ

关于球坐标系我们就说到这,接下来我们就可以说一下正交相机旋转轨道的具体代码实现啦。

3-4-正交相机旋转轨道的代码实现

在这里,我会把之前的位移轨道和缩放轨道合着现在的旋转轨道一起来说,即此把之前的知识也一起捋一遍。

场景还是之前的场景,我就不再多说了,咱们直接说轨道。

1.声明基础数据

//鼠标事件集合 const mouseButtons = new Map([ [0, 'rotate'], [2, 'pan'], ]) //轨道状态 let state = 'none' //2PI const pi2 = Math.PI * 2 //鼠标拖拽的起始位和结束位,无论是左键按下还是右键按下 const [dragStart, dragEnd] = [ new Vector2(), new Vector2(), ]

2.声明轨道相关的基础数据

平移轨道 //平移量 const panOffset = new Vector3() //是否沿相机y轴平移相机 const screenSpacePanning = true

缩放轨道 //缩放系数 const zoomScale = 0.95

旋转轨道 //相机视点相对于目标的球坐标 const spherical = new Spherical() .setFromVector3( camera.position.clone().sub(target) )

3.取消右击菜单的显示

canvas.addEventListener('contextmenu', event => { event.preventDefault() })

4.指针按下时,设置拖拽起始位,获取轨道控制器状态。

canvas.addEventListener('pointerdown', ({ clientX, clientY, button }) => { dragStart.set(clientX, clientY) state = mouseButtons.get(button) })

5.指针移动时,若控制器处于平移状态,平移相机;若控制器处于旋转状态,旋转相机。

canvas.addEventListener('pointermove', ({ clientX, clientY }) => { dragEnd.set(clientX, clientY) switch (state) { case 'pan': pan(dragEnd.clone().sub(dragStart)) break case 'rotate': rotate(dragEnd.clone().sub(dragStart)) break } dragStart.copy(dragEnd) })

平移方法 function pan({ x, y }) { const cameraW = camera.right - camera.left const cameraH = camera.top - camera.bottom const ratioX = x / canvas.clientWidth const ratioY = y / canvas.clientHeight const distanceLeft = ratioX * cameraW const distanceUp = ratioY * cameraH const mx = new Vector3().setFromMatrixColumn(camera.matrix, 0) const vx = mx.clone().multiplyScalar(-distanceLeft) const vy = new Vector3() if (screenSpacePanning) { vy.setFromMatrixColumn(camera.matrix, 1) } else { vy.crossVectors(camera.up, mx) } vy.multiplyScalar(distanceUp) panOffset.copy(vx.add(vy)) update() }

旋转方法 function rotate({ x, y }) { const { clientHeight } = canvas spherical.theta -= pi2 * x / clientHeight // yes, height spherical.phi -= pi2 * y / clientHeight update() }

6.滚轮滚动时,缩放相机

canvas.addEventListener('wheel', handleMouseWheel) function handleMouseWheel({ deltaY }) { if (deltaY < 0) { dolly(1 / zoomScale) } else { dolly(zoomScale) } update() }

function dolly(dollyScale) { camera.zoom *= dollyScale camera.updateProjectionMatrix() }

7.更新相机,并渲染

function update() { //基于平移量平移相机 target.add(panOffset) camera.position.add(panOffset)

//基于旋转量旋转相机
const rotateOffset = new Vector3()
.setFromSpherical(spherical)
camera.position.copy(
    target.clone().add(rotateOffset)
)

//更新投影视图矩阵
camera.lookAt(target)
camera.updateMatrixWorld(true)
pvMatrix.multiplyMatrices(
    camera.projectionMatrix,
    camera.matrixWorldInverse,
)

//重置旋转量和平移量
spherical.setFromVector3(
    camera.position.clone().sub(target)
)
panOffset.set(0, 0, 0);

// 渲染
render()

}

正交相机旋转轨道的基本实现原理就是这样,接下来我们再对其做一下补充与扩展。

3-5-限制旋转轴

在three.js 的轨道控制器里,无法限制旋转轴,比如我只想横向旋转相机,或者竖向旋转相机。

这样的需求,是我们在实战项目中,遇到的比较卡人的需求之一。

一旦我们不理解其底层原理,那就很难实现这个看似简单的需求。

不过,现在既然我们已经说了其底层原理,那实现起来也就真的简单了。

1.声明一个控制旋转方向的属性

const rotateDir = 'xy'

x:可以在x方向旋转相机 y:可以在y方向旋转相机 xy:可以在x,y方向旋转相机

2.旋转方法,基于rotateDir属性约束旋转方向

function rotate({ x, y }) { const { clientHeight } = canvas const deltaT = pi2 * x / clientHeight // yes, height const deltaP = pi2 * y / clientHeight if (rotateDir.includes('x')) { spherical.theta -= deltaT } if (rotateDir.includes('y')) { spherical.phi -= deltaP } update() }

3-6-限制极角

之前我们说球坐标系的时候说过,其极角的定义域是[0,180°],所以我们在代码里也要对其做一下限制。

在rotate() 方法里做下调整即可。

//旋转 function rotate({ x, y }) { …… if (rotateDir.includes('y')) { const phi = spherical.phi - deltaP spherical.phi = Math.min( Math.PI, Math.max(0, phi) ) } …… }

然而,因为当球坐标里的极角等于0或180度的时候,方位角会失去意义,所以我们还不能在代码真的给极角0或180度,不然方位角会默认归零。

所以,我们需要分别给极角里的0和180度一个近似值。

spherical.phi = Math.min( Math.PI * 0.99999999, Math.max(0.00000001, phi) )

基于球坐标系的相机旋转轨道我们就说到这,其具体代码我们可以参考three.js里的OrbitControls 对象的源码。

接下来,我们再说一个另一种形式的正交相机旋转轨道。

4-轨迹球旋转

轨迹球这个名字,来自three.js 的TrackballControls 对象,其具体的代码实现便可以在这里找到。

轨迹球不像基于球坐标系的旋转轨道那样具有恒定的上方向。

轨迹球的上方向是一个垂直于鼠标拖拽方向和视线的轴,相机视点会基于此轴旋转。

轨迹球的上方向会随鼠标拖拽方向的改变而改变。

如下图:

接下来,咱们说一下具体的代码实现。

在three.js中,TrackballControls 和OrbitControls对象里的代码,不太像一个人写的。

因为我们完全可以沿用OrbitControls 里的一部分代码去写TrackballControls,比如鼠标在相机世界里的偏移量,而TrackballControls完全用一套风格迥异的代码从头写了一遍。

当然,我这里并不是说TrackballControls 不好,其原理和功能的实现方法依旧是很值得学习。

只是,我们在写轨迹球的时候,完全可以基于TrackballControls的实现原理,把OrbitControls给改一下,这样我们可以少写许多代码。

1.定义用于沿某个轴旋转相机视点的四元数

const quaternion = new Quaternion()

2.把之前的rotate()旋转方法改一下

function rotate({ x, y }) { const {right,left,top,bottom,matrix,position}=camera const {clientWidth,clientHeight}=canvas

// 相机宽高
const cameraW = right - left
const cameraH = top - bottom

// 鼠标位移距离在画布中的占比
const ratioX = x / clientWidth
const ratioY = -y / clientHeight

//基于高度的x位置比-用于旋转量的计算
const ratioXBaseHeight = x / clientHeight
//位移量
const ratioLen=new Vector2(ratioXBaseHeight, ratioY).length() 
//旋转量
const angle = ratioLen* pi2

// 在相机世界中的位移距离
const distanceLeft = ratioX * cameraW
const distanceUp = ratioY * cameraH

// 相机本地坐标系的x,y轴
const mx = new Vector3().setFromMatrixColumn(camera.matrix, 0)
const my = new Vector3().setFromMatrixColumn(camera.matrix, 1)

// 将鼠标在相机世界的x,y轴向的位移量转换为世界坐标位
const vx = mx.clone().multiplyScalar(distanceLeft)
const vy = my.clone().multiplyScalar(distanceUp)

//鼠标在s'j'z中的位移方向-x轴
const moveDir=vx.clone().add(vy).normalize()

//目标点到视点的单位向量-z轴
const eyeDir = camera.position.clone().sub(target).normalize()

//基于位移方向和视线获取旋转轴-上方向y轴
const axis = moveDir.clone().cross(eyeDir)

//基于旋转轴和旋转量建立四元数
quaternion.setFromAxisAngle(axis, angle)

update()

}

3.在update()更新方法中,基于四元数设置相机视点位置,并更新相机上方向

/* 更新相机,并渲染 */ function update() { ……

//旋转视线
const rotateOffset = camera.position
.clone()
.sub(target)
.applyQuaternion(quaternion)

//基于最新视线设置相机位置
camera.position.copy(
    target.clone().add(rotateOffset)
)
//旋转相机上方向 
camera.up.applyQuaternion(quaternion)

……

//重置旋转量和平移量
panOffset.set(0, 0, 0)
quaternion.setFromRotationMatrix(new Matrix4())

……

}

修改完相机的视点位和上方后,要记得重置四元数,以避免在拖拽和缩放时,造成相机旋转。

注:

轨迹球的操控难度是要比球坐标系轨道大的,它常常让不熟练其特性的操作者找不到北,所以在实际项目中还是球坐标系轨道用得比较多。

第九章 透视相机轨道控制器

之前咱们说过了正交相机的轨道控制器,接下来再看透视相机的轨道控制器,就会方便很多。

1-透视相机的位移轨道

透视相机的位移轨道和正交相机的位移轨道是相同原理的,都是对相机视点和目标点的平移。

接下来咱们直接说一下代码实现。

1.建透视交相机

const eye = new Vector3(0, 0.5, 1) const target = new Vector3(0, 0, -2.5) const up = new Vector3(0, 1, 0)

const [fov, aspect, near, far] = [ 45, canvas.width / canvas.height, 1, 20 ] const camera = new PerspectiveCamera(fov, aspect, near, far) camera.position.copy(eye) camera.lookAt(target) camera.updateWorldMatrix(true)

2.在正交相机的位移轨道的基础上改一下pan方法

将鼠标在画布中的位移量转目标平面位移量 const {matrix,position,up}=camera const {clientWidth,clientHeight}=canvas

//视线长度:相机视点到目标点的距离 const sightLen = position.clone().sub(target).length() //视椎体垂直夹角的一半(弧度) const halfFov = fov * Math.PI / 360 //目标平面的高度 const targetHeight = sightLen * Math.tan(halfFov) * 2 //目标平面与画布的高度比 const ratio = targetHeight / clientHeight //画布位移量转目标平面位移量 const distanceLeft = x * ratio const distanceUp = y * ratio

注:目标平面是过视点,平行于裁剪面的平面

将鼠标在目标平面中的位移量转世界坐标 //相机平移方向 //鼠标水平运动时,按照相机本地坐标的x轴平移相机 const mx = new Vector3().setFromMatrixColumn(matrix, 0) //鼠标水平运动时,按照相机本地坐标的y轴,或者-z轴平移相机 const myOrz = new Vector3() if (screenSpacePanning) { //y轴,正交相机中默认 myOrz.setFromMatrixColumn(matrix, 1) } else { //-z轴,透视相机中默认 myOrz.crossVectors(up, mx) }

//目标平面位移量转世界坐标 const vx = mx.clone().multiplyScalar(-distanceLeft) const vy = myOrz.clone().multiplyScalar(distanceUp) panOffset.copy(vx.add(vy))

透视相机的位移轨道就这么简单,接下来咱们说一下透视相机的缩放轨道。

2-透视相机的缩放轨道

透视相机缩放是通过视点按照视线的方向,接近或者远离目标点来实现的。

2-1-举个例子

已知:

视点e=5 目标点t=15 (视点即将位移的距离)/(位移前,视点与与目标点的距离)= 0.4

求:视点移动2次后的位置

解:

视点第1次移动后的位置:5+(15-5)*0.4=9

视点第2次移动后的位置:9+(15-9)*0.4= 11.4

基本原理就是这样,视点移动n此后的位置都可以按照上面的逻辑来计算。

接下来,咱们看一下代码实现。

2-2-代码实现

我们可以直接在正交相机缩放轨道的基础上做一下修改。

function dolly(dollyScale) { camera.position.lerp(target, 1 - dollyScale) }

lerp ( v : Vector3, alpha : Float ) 按比例去两点之间的插值

其源码如下:

lerp( v, alpha ) { this.x += ( v.x - this.x ) * alpha; this.y += ( v.y - this.y ) * alpha; this.z += ( v.z - this.z ) * alpha; return this; }

dollyScale:(位移之后视点与目标点的距离)/(位移前,视点与与目标点的距离)

1-dollyScale:(视点即将位移的距离)/(位移前,视点于与目标点的距离)

正交相机缩放轨道的基本实现原理就是这么简单。

然而,后面我们还得用球坐标对相机进行旋转,球坐标是已经涵盖了相机视点位的。

因此,我们还可以直接把相机视点位写进球坐标里。

2-3-球坐标缩放

1.像正交相机的旋转轨道那样,定义球坐标对象。

const spherical = new Spherical() .setFromVector3( camera.position.clone().sub(target) )

2.修改旋转方法

function dolly(dollyScale) { spherical.radius*=dollyScale }

3.更新方法也和正交相机的旋转轨道一样

function update() { //基于平移量平移相机 target.add(panOffset) camera.position.add(panOffset)

//基于球坐标缩放和旋转相机
const rotateOffset = new Vector3()
.setFromSpherical(spherical)
camera.position.copy(
    target.clone().add(rotateOffset)
)

//更新投影视图矩阵
camera.lookAt(target)
camera.updateMatrixWorld(true)
pvMatrix.multiplyMatrices(
    camera.projectionMatrix,
    camera.matrixWorldInverse,
)

//重置球坐标和平移量
spherical.setFromVector3(
    camera.position.clone().sub(target)
)
panOffset.set(0, 0, 0)

// 渲染
render()

}

3-透视相机的旋转轨道

透视相机的旋转轨道和正交相机的实现原理都是一样的,可以用球坐标系实现,也可以用轨迹球实现。

基于球坐标系的旋转轨道,可直接参考正交相机基于球坐标系的旋转轨道来写。 /* 旋转轨道 */ const spherical = new Spherical() .setFromVector3( camera.position.clone().sub(target) ) //'xy','x','y' const rotateDir = 'xy'

……

/* 指针移动时,若控制器处于平移状态,平移相机;若控制器处于旋转状态,旋转相机。 */ canvas.addEventListener('pointermove', ({ clientX, clientY }) => { dragEnd.set(clientX, clientY) switch (state) { case 'pan': pan(dragEnd.clone().sub(dragStart)) break case 'rotate': rotate(dragEnd.clone().sub(dragStart)) break } dragStart.copy(dragEnd) }) ……

// 旋转方法 function rotate({ x, y }) { const { clientHeight } = canvas const deltaT = pi2 * x / clientHeight const deltaP = pi2 * y / clientHeight if (rotateDir.includes('x')) { spherical.theta -= deltaT } if (rotateDir.includes('y')) { const phi = spherical.phi - deltaP spherical.phi = Math.min( Math.PI * 0.99999999, Math.max(0.00000001, phi) ) } update() }

function update() { //基于平移量平移相机 target.add(panOffset) camera.position.add(panOffset)

//基于球坐标缩放相机
const rotateOffset = new Vector3()
.setFromSpherical(spherical)
camera.position.copy(
    target.clone().add(rotateOffset)
)

//更新投影视图矩阵
camera.lookAt(target)
camera.updateMatrixWorld(true)
pvMatrix.multiplyMatrices(
    camera.projectionMatrix,
    camera.matrixWorldInverse,
)

//重置旋转量和平移量
spherical.setFromVector3(
    camera.position.clone().sub(target)
)
panOffset.set(0, 0, 0)

// 渲染
render()

}

对于轨迹球的旋转轨道,基于正交相机轨迹球旋转的代码略作调整即可。 /* 旋转轨道 */ const quaternion = new Quaternion()

function rotate({ x, y }) { const { matrix, position, fov } = camera const { clientHeight } = canvas

/* 1.基于鼠标拖拽距离计算旋转量 */
// 鼠标位移距离在画布中的占比
const ratioY = -y / clientHeight
//基于高度的x位置比-用于旋转量的计算
const ratioBaseHeight = x / clientHeight
//位移量  
const ratioLen = new Vector2(ratioBaseHeight, ratioY).length()
//旋转量
const angle = ratioLen * pi2

/* 2.将鼠标在画布中的位移量转目标平面位移量 */
//视线长度:相机视点到目标点的距离
const sightLen = position.clone().sub(target).length()
//视椎体垂直夹角的一半(弧度)
const halfFov = fov * Math.PI / 360
//目标平面的高度
const targetHeight = sightLen * Math.tan(halfFov) * 2
//目标平面与画布的高度比
const ratio = targetHeight / clientHeight
//画布位移量转目标平面位移量
const distanceLeft = x * ratio
const distanceUp = -y * ratio

 /* 3.将鼠标在目标平面中的位移量转世界坐标,并从中提取鼠标在世界坐标系中的位移方向 */
// 相机本地坐标系的x,y轴
const mx = new Vector3().setFromMatrixColumn(matrix, 0)
const my = new Vector3().setFromMatrixColumn(matrix, 1)
// 将鼠标在相机世界的x,y轴向的位移量转换为世界坐标位
const vx = mx.clone().multiplyScalar(distanceLeft)
const vy = my.clone().multiplyScalar(distanceUp)
//鼠标在世界坐标系中的位移方向-x轴
const moveDir = vx.clone().add(vy).normalize()

/* 4.基于位移方向和视线获取旋转轴 */
//目标点到视点的单位向量-z轴
const eyeDir = position.clone().sub(target).normalize()
//基于位移方向和视线获取旋转轴-上方向y轴
const axis = moveDir.clone().cross(eyeDir)

/* 5.基于旋转轴和旋转量更新四元数 */
quaternion.setFromAxisAngle(axis, angle)

update()

}

function update() { //基于平移量平移相机 target.add(panOffset) camera.position.add(panOffset)

//基于旋转量旋转相机
const rotateOffset = camera.position.clone()
.sub(target)
.applyQuaternion(quaternion)

camera.position.copy(
    target.clone().add(rotateOffset)
)
camera.up.applyQuaternion(quaternion)

//更新投影视图矩阵
camera.lookAt(target)
camera.updateMatrixWorld(true)
pvMatrix.multiplyMatrices(
    camera.projectionMatrix,
    camera.matrixWorldInverse,
)

//重置旋转量和平移量
quaternion.setFromRotationMatrix(new Matrix4())
panOffset.set(0, 0, 0)

// 渲染
render()

}

第十章 封装相机轨道对象

接下来我会参考three.js 里的OrbitControls对象,封装一个轨道控制器出来。

至于轨迹球的封装,我就留给大家来练手了,可以参考着TrackballControls 来实现。

首先我们先对之前所学的正交相机轨道和透视相机轨道做一个差异的对比:

旋转轨道 正交相机和透视相机的旋转轨道都是一样的,都是使用球坐标,让相机视点围绕目标点做的旋转。 位移轨道 正交相机的位移轨道是鼠标从canvas画布到近裁剪面,再到世界坐标系的位移量的转换,最后这个位移量会同时作用于相机的目标点和视点。 透视相机的位移轨道是鼠标从canvas画布到目标平面,再到世界坐标系的位移量的转换,最后这个位移量会同时作用于相机的目标点和视点。 缩放轨道 正交相机的缩放轨道是通过对其可视区域的宽高尺寸的缩放实现的。 透视相机的缩放轨道是通过相机视点在视线上的位移实现的。

整体的原理就是这样,接下来就可以封装轨道控制器了。

1-轨道控制器的封装

我这里没有原封不动抄袭there.js,我参考其整体原理,做了一下简化和取舍。

大家若有更好的想法,也可以自己封装一个出来,然后努力超越three.js。

整体代码如下:

import { Matrix4, Vector2, Vector3,Spherical } from 'unpkg.com/three/build…';

const pi2 = Math.PI * 2 const pvMatrix=new Matrix4()

const defAttr = () => ({ camera: null, dom: null, target: new Vector3(), mouseButtons:new Map([ [0, 'rotate'], [2, 'pan'], ]), state: 'none', dragStart: new Vector2(), dragEnd: new Vector2(), panOffset: new Vector3(), screenSpacePanning: true, zoomScale: 0.95, spherical:new Spherical(), rotateDir: 'xy', })

export default class OrbitControls{ constructor(attr){ Object.assign(this, defAttr(), attr) this.updateSpherical() this.update() } updateSpherical() { const {spherical,camera,target}=this spherical.setFromVector3( camera.position.clone().sub(target) ) } pointerdown({ clientX, clientY,button }) { const {dragStart,mouseButtons}=this dragStart.set(clientX, clientY) this.state = mouseButtons.get(button) } pointermove({ clientX, clientY }) { const { dragStart, dragEnd, state, camera: { type } } = this dragEnd.set(clientX, clientY) switch (state) { case 'pan': this[pan${type}] (dragEnd.clone().sub(dragStart)) break case 'rotate': this.rotate(dragEnd.clone().sub(dragStart)) break } dragStart.copy(dragEnd) } pointerup() { this.state = 'none' } wheel({ deltaY }) { const { zoomScale, camera: { type } } = this let scale=deltaY < 0?zoomScale:1 / zoomScale this[dolly${type}] (scale) this.update() } dollyPerspectiveCamera(dollyScale) { this.spherical.radius *= dollyScale } dollyOrthographicCamera(dollyScale) { const {camera}=this camera.zoom *= dollyScale camera.updateProjectionMatrix() } panPerspectiveCamera({ x, y }) { const { camera: { matrix, position, fov,up }, dom: { clientHeight }, panOffset,screenSpacePanning,target } = this

    //视线长度:相机视点到目标点的距离
    const sightLen = position.clone().sub(target).length()
    //视椎体垂直夹角的一半(弧度)
    const halfFov = fov * Math.PI / 360
    //目标平面的高度
    const targetHeight = sightLen * Math.tan(halfFov) * 2
    //目标平面与画布的高度比
    const ratio = targetHeight / clientHeight
    //画布位移量转目标平面位移量
    const distanceLeft = x * ratio
    const distanceUp = y * ratio

    //相机平移方向
    //鼠标水平运动时,按照相机本地坐标的x轴平移相机
    const mx = new Vector3().setFromMatrixColumn(matrix, 0)
    //鼠标水平运动时,按照相机本地坐标的y轴,或者-z轴平移相机
    const myOrz = new Vector3()
    if (screenSpacePanning) {
        //y轴,正交相机中默认
        myOrz.setFromMatrixColumn(matrix, 1)
    } else {
        //-z轴,透视相机中默认
        myOrz.crossVectors(up, mx)
    }
    //目标平面位移量转世界坐标
    const vx = mx.clone().multiplyScalar(-distanceLeft)
    const vy = myOrz.clone().multiplyScalar(distanceUp)
    panOffset.copy(vx.add(vy))

    this.update()
}

panOrthographicCamera({ x, y }) {
    const {
        camera: { right, left, top, bottom, matrix, up },
        dom: { clientWidth, clientHeight },
        panOffset,screenSpacePanning
    } = this

    const cameraW = right - left
    const cameraH = top - bottom
    const ratioX = x / clientWidth
    const ratioY = y / clientHeight
    const distanceLeft = ratioX * cameraW
    const distanceUp = ratioY * cameraH
    const mx = new Vector3().setFromMatrixColumn(matrix, 0)
    const vx = mx.clone().multiplyScalar(-distanceLeft)
    const vy = new Vector3()
    if (screenSpacePanning) {
        vy.setFromMatrixColumn(matrix, 1)
    } else {
        vy.crossVectors(up, mx)
    }
    vy.multiplyScalar(distanceUp)
    panOffset.copy(vx.add(vy))
    this.update()
}


rotate({ x, y }) {
    const {
        dom: { clientHeight },
        spherical, rotateDir,
    } = this
    const deltaT = pi2 * x / clientHeight
    const deltaP = pi2 * y / clientHeight
    if (rotateDir.includes('x')) {
        spherical.theta -= deltaT
    }
    if (rotateDir.includes('y')) {
        const phi = spherical.phi - deltaP
        spherical.phi = Math.min(
            Math.PI * 0.99999999,
            Math.max(0.00000001, phi)
        )
    }
    this.update()
}

update() {
    const {camera,target,spherical,panOffset} = this
    //基于平移量平移相机
    target.add(panOffset)
    camera.position.add(panOffset)

    //基于球坐标缩放相机
    const rotateOffset = new Vector3()
    .setFromSpherical(spherical)
    camera.position.copy(
        target.clone().add(rotateOffset)
    )

    //更新投影视图矩阵
    camera.lookAt(target)
    camera.updateMatrixWorld(true)

    //重置旋转量和平移量
    spherical.setFromVector3(
        camera.position.clone().sub(target)
    )
    panOffset.set(0, 0, 0)
}

getPvMatrix() {
    const { camera: { projectionMatrix, matrixWorldInverse } } = this
    return pvMatrix.multiplyMatrices(
        projectionMatrix,
        matrixWorldInverse,
    )
}

}

接下来咱们将其实例化一下看看。

2-轨道控制器的实例化

OrbitControls对象就像three.js 里的样,可以自动根据相机类型去做相应的轨道变换。

/* 透视相机 / / const eye = new Vector3(0, 0.5, 1) const target = new Vector3(0, 0, -2.5) const up = new Vector3(0, 1, 0) const [fov, aspect, near, far] = [ 45, canvas.width / canvas.height, 1, 20 ] const camera = new PerspectiveCamera(fov, aspect, near, far) camera.position.copy(eye) */

/* 正交相机 */ const halfH = 2 const ratio = canvas.width / canvas.height const halfW = halfH * ratio const [left, right, top, bottom, near, far] = [ -halfW, halfW, halfH, -halfH, 1, 8 ] const eye = new Vector3(1, 1, 2) const target = new Vector3(0, 0, -3) const up = new Vector3(0, 1, 0)

const camera = new OrthographicCamera( left, right, top, bottom, near, far ) camera.position.copy(eye)

const pvMatrix = new Matrix4()

……

/* 实例化轨道控制器 */ const orbit = new OrbitControls({ camera, target, dom: canvas, }) pvMatrix.copy(orbit.getPvMatrix()) render()

/* 取消右击菜单的显示 */ canvas.addEventListener('contextmenu', event => { event.preventDefault() })

/* 指针按下时,设置拖拽起始位,获取轨道控制器状态。 */ canvas.addEventListener('pointerdown', event => { orbit.pointerdown(event) })

/* 指针移动时,若控制器处于平移状态,平移相机;若控制器处于旋转状态,旋转相机。 */ canvas.addEventListener('pointermove', event => { orbit.pointermove(event) pvMatrix.copy(orbit.getPvMatrix()) render() }) canvas.addEventListener('pointerup', event => { orbit.pointerup(event) })

//滚轮事件 canvas.addEventListener('wheel', event => { orbit.wheel(event) pvMatrix.copy(orbit.getPvMatrix()) render() })

好啦,到目前为止,我们相机轨道的底层原理分析就说到这了。