跳至主要內容

进入三维世界-02

oorzc大约 26 分钟

进入三维世界-02

第五章 透视投影矩阵

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

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

image-20210707221800927
image-20210707221800927

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

透视投影矩阵是一个专业图形程序员所必备的,只有对其有了透彻的理解,我们才能有底气将薪资要到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≠0

比如:

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

1-2-线性补间运算

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

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

image-20211126212712479
image-20211126212712479

已知:

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

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

  • x属于N

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

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

  1. 比例式:
(x-minN)/(maxN-minN)=(y-minM)/(maxM-minM)
  1. 点斜式
k=(maxM-minM)/(maxN-minN)
b=minM-minN*k
y=kx+b

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

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

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

2-认识透视投影矩阵

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

image-20210716131916941
image-20210716131916941
image-20210716131951721
image-20210716131951721

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

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

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

答:从透视到正交。

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

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

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

3-计算透视投影矩阵

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

image-20210716131951721
image-20210716131951721
t=n*tan(fov/2)
b=-t
r=t*aspect
l=-r

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

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

image-20210719220653381
image-20210719220653381

由相似三角形性质得:

x1/x2=y1/y2=z1/z2

因为:

z2=-n

所以:

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

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

image-20210719222811832
image-20210719222811832

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

image-20211126225339288
image-20211126225339288

设: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:

image-20211126214639931
image-20211126214639931
  • 当z1=-n 时,z3=-1,z4=-1*-z1 ,即:
z4=k*z1+b
-1*n=k*-n+b
-n=-kn+b
b=kn-n
  • 当z1=-f 时,z3=1,z4=1*-z1,即:
z4=k*z1+b
1*f=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-透视投影矩阵牛刀小试

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

image-20210721172503038
image-20210721172503038

1.着色器

<script id="vertexShader" type="x-shader/x-vertex">
  attribute vec4 a_Position;
  uniform mat4 u_ProjectionMatrix;
  void main(){
  	gl_Position = u_ProjectionMatrix*a_Position;
  }
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
  precision mediump float;
  uniform vec4 u_Color;
  void main(){
  	gl_FragColor=u_Color;
  }
</script>

2.初始化着色器

import { initShaders } from '../jsm/Utils.js';
import { Matrix4,PerspectiveCamera, Vector3, Quaternion, Object3D, OrthographicCamera } from 'https://unpkg.com/three/build/three.module.js';
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.在顶点着色器里把投影矩阵变成投影视图矩阵。

<script id="vertexShader" type="x-shader/x-vertex">
    attribute vec4 a_Position;
    uniform mat4 u_PvMatrix;
    void main(){
      gl_Position = u_PvMatrix*a_Position;
    }
</script>

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
},

效果如下:

image-20210721214518946
image-20210721214518946

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

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_PvMatrix*u_ModelMatrix*a_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,
    ]
)

效果如下:

image-20210721214518946
image-20210721214518946

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

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

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

  • 旋转

    • 鼠标左键拖拽
    • 单手指移动
  • 缩放

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

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

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

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

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

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

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

1-正交相机的位移轨道

1-1-搭建场景

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

  • 着色器
<script id="vertexShader" type="x-shader/x-vertex">
    attribute vec4 a_Position;
    uniform mat4 u_PvMatrix;
    uniform mat4 u_ModelMatrix;
    void main(){
      gl_Position = u_PvMatrix*u_ModelMatrix*a_Position;
    }
</script>
<script id="fragmentShader" type="x-shader/x-fragment">
    precision mediump float;
    uniform vec4 u_Color;
    void main(){
      gl_FragColor=u_Color;
    }
</script>
  • 初始化着色器
import { initShaders } from '../jsm/Utils.js';
import {
    Matrix4, PerspectiveCamera, Vector2, Vector3, Quaternion, Object3D,
    OrthographicCamera
} from 'https://unpkg.com/three/build/three.module.js';
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)
}
  • 基于拖拽距离(像素单位)移动相机
image-20210728193306345
image-20210728193306345
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里就是近裁剪面。

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

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

image-20210708084917049
image-20210708084917049

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

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-正交相机的旋转轨道的概念

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

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

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

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

image-20210812213556620

已知:

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

则:

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

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

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

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

image-20210812213556620

已知:

  • 三维坐标系[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-球坐标系

Ⅰ 球坐标系的概念

image-20210812213556620

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

球坐标由以下分量构成:

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

注:

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

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

Ⅱ 三维坐标转球坐标

image-20210812213556620

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

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

解:

求半径r:

r=sqrt(++)

求极角φ的方法有三种:

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

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

θ=acos(x/(r*sinφ))
θ=asin(z/(r*sinφ))
θ=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=r*sinφ*cosθ
y=r*cosφ
z=r*sinφ*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 对象,其具体的代码实现便可以在这里找到。

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

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

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

如下图:

image-20210818175032171

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

在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())

    ……
}

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

注:

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

这里大家先知道有这么回事即可,以后在工作中遇到了,不要不知道是什么东西就好。

总结

关于正交相机的位移、缩放、旋转轨道,我们就说到这。

我们这里重点说了其底层的核心实现原理。

因为时间原因,我并没有给大家做对象的封装,就直接给展开说了。

课后,大家可以参考着three.js,自己封装一个轨道控制器出来。

然后,我们还可以在这基础上做一些扩展功能,比如限制平移轴向和平移距离,限制缩放范围,监听相机事件……

这些我就先不说了,因为实现了核心原理后,那些都是简单的业务逻辑而已。

之后大家若有相关问题,我们也可以在群里说。

上次编辑于:
贡献者: oorzc