Three.js 基础

189 阅读17分钟

Three.js 基础

数学基础

常用三角函数值:

  • sqrt(3)=1.732sqrt(3) = 1.732
  • sqrt(2)=1.414sqrt(2) = 1.414
  • sqrt(2)/2=0.707sqrt(2) / 2 = 0.707

常用向量值:

  • sqrt(3)/3=0.577sqrt(3) / 3 = 0.577 立方体对角线单位向量

仅讨论四维向量与矩阵,欧拉角和四元数作为补充

Vector

一个四维列向量:

vector=[x,y,z,w]Tvector = [x,y,z,w]^T

其中

  • x, y, z三个值可以在三维坐标系中确定一个点的位置

  • 坐标系原点(0, 0, 0)不再另行表示

那么

  • 如果w为0,那么vector就表示从原点到(x, y, z)的连线指向(x, y, z)的这个方向
  • 如果w不为0,那么vector就表示一个点的位置,w为从原点到这个点的距离

Matrix

image.png

其中[m11,m21,m31,0]T[m_{11}, m_{21}, m_{31}, 0]^T表示旋转后X轴所在的新轴位置,及缩放系数(新向量长度/1)

同理,[m12,m22,m32,0]T[m_{12}, m_{22}, m_{32}, 0]^T表示旋转后Y轴所在的新轴位置及缩放系数;[m13,m23,m33,0]T[m_{13}, m_{23}, m_{33}, 0]^T表示旋转后Z轴所在的新轴位置及缩放系数

[tx,ty,tz,1]T[t_x, t_y, t_z, 1]^T表示旋转后基于新X, Y, Z轴进行平移

缩放、旋转、平移的顺序参考opengl-tutorial##累积变换

参考注意行优先列优先的顺序,注意three.js中的Matrix,用set赋值时是行优先,而打印出来是列优先填充的,即

const m = new THREE.Matrix4();

m.set( 11, 12, 13, 14,
    21, 22, 23, 24,
    31, 32, 33, 34,
    41, 42, 43, 44 );

console.log(m);

image.png

补充:欧拉角 Euler

欧拉角是除了变换矩阵之外的另一种描述物体旋转的方式,通过给定绕XYZ三个轴进行旋转的角度和旋转顺序(旋转顺序不同会导致结果不同,这里不详细说明),来描述物体旋转。

欧拉角的局限性:
万向节死锁

动态欧拉角:当一个轴已经旋转过了,那么它不会再随着后面的角再次旋转。

示例如图,按照红绿蓝的顺序从外向里转动,那么先动的轴不会随着后动的轴转动 (参考无伤理解欧拉角中的“万向死锁”现象) image.png 没有万向节死锁时,对任意方向上的旋转都可以只按一个轴旋转达成

假设旋转顺序为XYZ,

  • 第一次旋转绕X轴,带动Y轴和Z轴旋转
  • 第二次旋转绕Y轴,带动Z轴旋转,并将Z轴旋转到了X轴平行的位置(例如旋转90度)
  • 第三次旋转绕Z轴,但是此时Z轴与X轴平行(蓝圈与红圈共面)

此时出现了万向节死锁问题:即此时缺少了一个方向上的自由度,如果想要在这个方向上移动的话,那么需要动用其他两个轴来达成想要的移动,那么会导致中间状态不是线形变化,如果只观测首尾状态那么没有影响,但如果需要动画观测,则会出现弧线变化 (参考 Euler (gimbal lock) Explained

image.pngimage.png

补充:四元数 Quaternion

简单理解,四元数是欧拉角的上位替换,用四个值表示绕任意轴的旋转并且没有万向节死锁问题、支持线形插值。

image.png

Matrix4、欧拉角与四元数

欧拉角和四元数可以替换,四元数没有万向节死锁并且支持线形插值。

但是Matrix4中的旋转矩阵可以表示的内容并不能被欧拉角或四元数完全替代,例如以原点对称镜像

原点对称镜像:

const addBox = (x,y,z) => {
    const geometry = new THREE.BoxGeometry(10, 5, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const cube = new THREE.Mesh(geometry, material);
    // 将所有轴正负方向反转
    const matrix =  new THREE.Matrix4(
        -1, 0,  0, x,
        0, -1,  0, y,
        0,  0, -1, z,
        0,  0,  0, 1,
    )
    cube.applyMatrix4(matrix)
    console.log(
        {
            'cube.rotation': cube.rotation,
            'cube.scale': cube.scale
        }
    )
    const axis = new THREE.AxesHelper(10);
    cube.add(axis);
    scene.add(cube);
    return cube;
};
const box = addBox(10,10,10);

image.png

根据原点镜像对称不是一个可以用欧拉角表示的变换,需要在欧拉角的基础上再附加scale来进行表示 image.png

场景

Scene

一切继承自Object3D的类(Cameras、Lights、Mesh)的实例都应该被添加到Scene中使用。

Light

光源主要是按照来源进行区分。

  • 不能投射阴影的光源: 环境光、半球光、平面光
  • 可以投射阴影的充分条件:
    1. 光源是一个点(点光源、聚光灯)
    2. 光源方向一致(平行光源、聚光灯)
  • 环境光AmbientLight

    环境光会均匀的照亮场景中的所有物体。
    环境光不能用来投射阴影,因为它没有方向。

  • 点光源(PointLight)

    • 从一个点向各个方向发射的光源。一个常见的例子是模拟一个灯泡发出的光。
    • 该光源可以投射阴影
  • 平行光(DirectionalLight)

    • 平行光是沿着特定方向发射的光。这种光的表现像是无限远,从它发出的光线都是平行的。常常用平行光来模拟太阳光的效果。 太阳足够远,因此我们可以认为太阳的位置是无限远,所以我们认为从太阳发出的光线也都是平行的。
    • 平行光可以投射阴影
  • 半球光(HemisphereLight)

    • 光源直接放置于场景之上,光照颜色从天空光线颜色渐变到地面光线颜色。
    • 半球光不能投射阴影。

  • 平面光光源(RectAreaLight)

    • 平面光光源从一个矩形平面上均匀地发射光线。这种光源可以用来模拟像明亮的窗户或者条状灯光光源。
    • 不支持阴影。
  • 聚光灯(SpotLight)

    • 光线从一个点沿一个方向射出,随着光线照射的变远,光线圆锥体的尺寸也逐渐增大。
    • 该光源可以投射阴影

实体

这里讨论的实体是添加在场景中的部分对象。

还有很多实体,参考three.js文档/物体

Mesh、Line、Group

  • Mesh

    • Mesh基于 Geometry和Material 描述的一个实体,继承自Object3D,是一个具有局部坐标系的实体
  • Line

    const points = []; 
    points.push( new THREE.Vector3( - 10, 0, 0 ) ); 
    points.push( new THREE.Vector3( 0, 10, 0 ) ); 
    points.push( new THREE.Vector3( 10, 0, 0 ) ); 
    const geometry = new THREE.BufferGeometry().setFromPoints( points );
    
    • Line 也是Object3D的子类,跟Mesh同层,基于BufferGeometry().setFromPoints方法绘制出的线框几何体再结合material绘制出Line实体
    const line = new THREE.Line( geometry, material );
    
  • Group

    • 将几个实体组合在一起,看做一个整体,提供一个新的局部坐标系,便于统一操作位姿

Material

材质描述了对象Object3D的外观。

材质决定了实体如何跟光照互动,模拟现实世界中的各种材质在光照下的不同视觉效果。

材质分类:
绘制Line
绘制Mesh

Geometries(GeometryBuffer)

TextGeometry

Cameras

参考这个例子来直观看到透视相机和正交相机的区别。

PerspectiveCamera(透视摄像机)

模拟人眼,近大远小

具有这些属性来定义相机的视锥体:

  • fov — 摄像机视锥体垂直视野角度
  • aspect — 摄像机视锥体长宽比
  • near — 摄像机视锥体近端面
  • far — 摄像机视锥体远端面

通过fovaspect可以修改可见视野角度和宽高比,注意是fov是从垂直方向给定的角度

通过nearfar来修改可见视野距离

const perspectiveCamera = new THREE.PerspectiveCamera(
    60,  // fov
    2,   // aspect
    10,  // near
    20   // far
)

const addPerspectiveCamera = () => {
    scene.add(perspectiveCamera)
    perspectiveCamera.position.set(0, 0, 0);
    const destination = new THREE.Vector3(0, 0, 1);
    perspectiveCamera.lookAt(destination);
    const rotation = new Euler().copy(perspectiveCamera.rotation);
    const cameraHelper = new THREE.CameraHelper(perspectiveCamera);
    scene.add(cameraHelper);
}
addPerspectiveCamera();

对以上代码,借助 CameraHelper,可以将添加的相机视锥体可视化:

image.png 垂直方向上为设定的fov = 60, 水平方向上为根据fov和aspect计算出的结果,这里水平方向计算结果应该是90deg

image.pngimage.png
垂直方向视锥角度 60度水平方向视锥角度 90度

OrthographicCamera(正交摄像机)

物体大小始终保持不变

具有这些属性来定义相机的视锥体:

  • left — 摄像机视锥体左侧面。
  • right — 摄像机视锥体右侧面。
  • top — 摄像机视锥体上侧面。
  • bottom — 摄像机视锥体下侧面。
  • near — 摄像机视锥体近端面。
  • far — 摄像机视锥体远端面。
const orthographicCamera = new THREE.OrthographicCamera(
    -10, // left
    10,  // right
    10,  // top
    -10, // bottom
    10,  // near
    20   // far
)
const addOrthographicCamera = () => {
    scene.add(orthographicCamera)
    orthographicCamera.position.set(0, 0, 0);
    const destination = new THREE.Vector3(0, 0, 1);
    orthographicCamera.lookAt(destination);
    const cameraHelper = new THREE.CameraHelper(orthographicCamera);
    scene.add(cameraHelper);
}
addOrthographicCamera();

对以上代码,借助 CameraHelper,可以将添加的相机视体可视化:

image.png 正交相机视体是一个矩形,根据left、right、top、bottom、near、far圈定正交相机视体。

透视相机与正交相机对比

在相机的视体中添加一个矩形Mesh:

const addBox = (x, y, z) => {
    const geometry = new THREE.BoxGeometry(5, 3, 1);
    const material = new THREE.MeshBasicMaterial({color: 0xffffff});
    const box = new THREE.Mesh(geometry, material);
    box.position.set(x, y, z);
    const axis = new THREE.AxesHelper(10);
    box.add(axis);
    scene.add(box);
    return box;
};
const box = addBox(0, 0, 15);

image.png

  1. 切换到正交相机观看:
补充:Layers

相机具有一个layers属性,其值为一个Layers对象。Layers类是与Object3D同层的一个类。对每个Object3D对象,都具有一个layers属性,用于控制该Object3D对象是否在某个camera中显示。当 camera 的内容被渲染时,与其共享图层相同的物体会被显示。每个对象都需要与一个 camera 共享图层。

补充:

如何获取任意一个点在相机上投影的二维坐标(相对屏幕坐标系,原点位于canvas左上角)?

借助该点在世界坐标系中的三维向量表示,并将其使用vector3.project()方法投射到相机上:

window.getProjectedPointPosition = () => {
    const point = box.position.clone();
    const projectedPoint = point.clone().project(currentCamera);
    const width = window.innerWidth;
    const height = window.innerHeight;
    const screenX = Math.round((projectedPoint.x + 1) * width / 2);
    const screenY = Math.round((-projectedPoint.y + 1) * height / 2);
    console.log(projectedPoint);
    console.log("Screen Coordinates:", screenX, screenY);
}
  • projectedPoint 是标准化的坐标,范围在 [-1, 1] 之间, -1 是屏幕的最左边,1 是屏幕的最右边,-1 是屏幕的最下边,1 是屏幕的最上边
  • 计算结果超出这个范围则不在视锥可见范围内

project方法内,相机的世界坐标的逆矩阵和projectionMatrix被用来计算投影:

project( camera ) {

    return this.applyMatrix4( camera.matrixWorldInverse ).applyMatrix4( camera.projectionMatrix );

}

其中,applyMatrix4执行的是如下运算,行向量右乘:

Vectorthis×worldTcamera×cameraTscreenVector_{this} \times ^{world}T_{camera} \times ^{camera}T_{screen}

另:如何获取屏幕上任意一点在三维世界坐标系中的位置?

这个问题是从2D获取3D位置,有一个自由度上的信息缺失,所以需要补充额外信息,即问题变为

如何获取屏幕上任意一点,其在三维世界坐标系中的射线,与某个Mesh上第一次相交的点的三维坐标系位置?

借助于Three.js的核心类:光线投射Raycaster

window.addEventListener('click', (event) => {
    // 同样需要将屏幕坐标转为[-1, 1]的标准化设备坐标(**Normalized Device Coordinates**)
    const NCD = new THREE.Vector2();
    console.log('x,y', event.clientX, event.clientY);
    // 一元方程转换
    // 对于x,屏幕从左到右x递增,NCD.x从-1变到1,NCD.x与x正相关,NCD.x = ax + b, a > 0, b < 0
    // 对于y,屏幕从上到下y递增,NCD.y从1变到-1,NCD.y与y负相关,NCD.y = ax + b, a < 0, b > 0
    NCD.x = (event.clientX / window.innerWidth) * 2 - 1;
    NCD.y = -(event.clientY / window.innerHeight) * 2 + 1;
    console.log('NCD', NCD)

    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(NCD, camera);    // 从相机沿NCD方向发出射线
    // 与box的相交点
    const intersects = raycaster.intersectObject(box);

    if (intersects.length > 0) {
        const point = intersects[0].point;
        console.log("Intersection point:", point);
    }
});

设置几何体的位姿的方法:

1. 通过设置四维matrix

参考数学基础

优势:可以对不同几何体中方便根据已有位置进行copy

缺点:对使用者来说需要阅读matrix,转为人类语言较不容易

2. 通过position、up、lookAt属性

  • step1: position可以确定物体的位置,固定3个自由度,还剩3个自由度
  • step2: lookAt 将物体 z 轴指向 世界坐标系中的目标点,还剩1个自由度
  • step3: up 在step1和step2基础上,将物体绕z轴旋转,使得物体的y轴与物体的up属性的向量在世界坐标系中重合/平行/共面;这一步可能无法达成,如果up向量与step2确定的z轴平行。(Three.js 会自动处理这种情况,选择一个合理的up方向。)

注意up属性的设置需要在lookAt调用之前

示例:
  1. 默认情况,up[0,1,0][0, 1, 0]
const addBox = (
    // position={x:0, y:0, z:0},
    //             lookAt={x:0, y:0, z:0},
    //             up={x:0, y:1, z:0},
    x,y,z
                ) => {
    const geometry = new THREE.BoxGeometry(10, 5, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const box = new THREE.Mesh(geometry, material);
    box.position.set(x, y, z); // step1: 设置位置
    // 默认情况,这行代码可以省略
    box.up = new THREE.Vector3(0, 1, 0); // step3: 沿 z 轴旋转,使得 y 轴 尽可能 与 世界坐标中的 cube.up向量平行/重合/共面
    box.lookAt(new THREE.Vector3(0, 0, 0)); // step2: z 轴指向 世界坐标原点
    const axis = new THREE.AxesHelper(100);
    box.add(axis);
    scene.add(box);
    return box;
};
const box = addBox(10,10,10);

image.png box的z轴指向世界坐标原点,并尽量让box的y轴与设定的up向量重合/平行/共面,图中是共面的情况。 如果up[1,1,1][-1, 1, -1], 会得到相同的显示结果,但是已经是重合/平行的情况了

  1. up[1,0,1][1, 0, -1]
const addBox = (x,y,z) => {
    const geometry = new THREE.BoxGeometry(10, 5, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const cube = new THREE.Mesh(geometry, material);
    cube.position.set(x, y, z); // step1: 设置位置
    cube.up = new THREE.Vector3(1, 0, -1); // step3: 沿 z 轴旋转,使得 y 轴 尽可能 与 世界坐标中的 cube.up向量平行/重合
    cube.lookAt(new THREE.Vector3(0, 0, 0)); // step2: z 轴指向 世界坐标原点
    const axis = new THREE.AxesHelper(100);
    cube.add(axis);
    console.log(cube);
    scene.add(cube);
    return cube;
};
const box = addBox(10,10,10);

image.pngup[1,0,1][1, 0, -1],box的z轴指向世界坐标原点,并尽量让box的y轴与设定的up向量重合/平行/共面,图中是重合/平行的情况。

补充:

  1. 当启用Control时,再对Camera使用lookAt方法需要注意:

    如果对camera启用了Control,那么再使用lookAt时,需要同步更新controls的target。

  2. 如果lookAt的target为相机当前所在位置,那么会出错,显示为视野内无内容

camera.lookAt(cube.position);
trackballControls.target.copy(cube.position);

3. 通过translate 和 rotation方法

对平移和旋转,有些场景希望能够在局部坐标系内动作(人在地球场景上的火车上行走/地球在太阳系场景内自转),而有些场景希望能够在全局坐标系内动作(火车在地球场景上运动/地球在太阳系场景内公转),这里的例子可能不准确,但核心都是为了使用不同坐标系来简化一个动作的描述。

rotation

Three.js提供了物体相对局部坐标旋转和相对世界坐标系旋转的API:

基于局部坐标系旋转
// 基于XYZ轴
.rotateX ( rad : Float ) : this
.rotateY ( rad : Float ) : this
.rotateZ ( rad : Float ) : this

// 基于任意轴
.rotateOnAxis ( axis : Vector3, angle : Float ) : this
.setRotationFromAxisAngle ( axis : Vector3, angle : Float ) : undefined //会采用四元数旋转

// 基于给定值
    // 基于转换矩阵
    .setRotationFromMatrix ( m : Matrix4 ) : undefined // 务必确保 m 具有有效的 旋转矩阵,否则请使用 .applyMatrix4
    .matrix = new THREE.Matrix4()
    // 基于欧拉角
    .setRotationFromEuler ( euler : Euler ) : undefined
    .rotation = new THREE.Euler(0,0,0, Euler.DEFAULT_ORDER)
    // 基于四元数
    .setRotationFromQuaternion ( q : Quaternion ) : undefined
    .quaternion = new THREE.Quaternion(0,0,0,1);
基于全局(世界/场景)坐标系旋转
// 基于任意轴
.rotateOnWorldAxis ( axis : Vector3, angle : Float) : this
.applyMatrix4 ( matrix : Matrix4 ) : undefined

applyMatrix4是常用且有效的方法

translate

同样具有基于局部坐标旋转和相对世界坐标系旋转的API:

基于局部坐标系平移
// 基于任意轴
.translateOnAxis ( axis : Vector3, distance : Float ) : this
// 基于XYZ轴
.translateX ( distance : Float ) : this
.translateY ( distance : Float ) : this
.translateZ ( distance : Float ) : this
基于全局(世界/场景)坐标系平移
.applyMatrix4 ( matrix : Matrix4 ) : undefined

applyMatrix4是常用且有效的方法

补充:Scale

一个 Vector3对象,表示在XYZ轴上的坐标缩放系数。

注意顺序:缩放、旋转、平移是Matrix4中约定的顺序

如果通过scale.set() rotation.set() position.set()进行位姿设定,也需要遵循这个顺序。

用户交互

Controls

简单罗列提供的手势交互方式:


  • 拖放控制器(DragControls)
    • 该类被用于提供一个拖放交互。
  • 飞行控制器(FlyControls)
    • FlyControls 启用了一种类似于数字内容创建工具(例如Blender)中飞行模式的导航方式。 你可以在3D空间中任意变换摄像机,并且无任何限制(例如,专注于一个特定的目标)。
  • 第一人称控制器(FirstPersonControls)
    • 该类是 FlyControls 的另一个实现。在人脑袋上加一个飞行控制器。 提供了更多在人情景下的更多API封装,例如蹲起时的相机移动速度左右上下看的相机移动边界约束
  • 指针锁定控制器(PointerLockControls)
    • 该类的实现是基于Pointer Lock API的。 对于第一人称3D游戏来说, PointerLockControls 是一个非常完美的选择。
  • 变换控制器(TransformControls)
    • 该类可提供一种类似于在数字内容创建工具(例如Blender)中对模型进行交互的方式,来在3D空间中变换物体。 和其他控制器不同的是,变换控制器不倾向于对场景摄像机的变换进行改变。
    • TransformControls 期望其所附加的3D对象是场景图的一部分。

开发调试

  • AxesHelper
  • CameraHelper