环境

本次是以学习为主,均为一些demo,不必要搞复杂。所以直接使用CDN资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>

<script type="module">
import * as THREE from '../build/three.module.js';

</script>
</body>

</html>

以上就是基本结构了

场景、物体、视角、渲染器

其实我们生活的世界就是三维世界,那么创建一个三维的视角的应用,其实就和创造世界差不多,总结起来三步:

  1. 创建一个世界
  2. 在世界中指定一个视角
  3. 创建物体

而且2、3步可以互换

而在ThreeJS中,多一个步骤,渲染到浏览器上

  1. 创建一个世界
  2. 创建camera(指定一个视角)
  3. 创建物体(导入模型,或者直接使用js创建
  4. 渲染到浏览器上

hello world

创建场景

1
2
// 创建场景(创建一个世界,这个世界就是我们3d展示的世界)
const scene = new THREE.Scene()

非常简单的直接new一个Scene对象

创建camera

1
2
// 创建相机(场景中,用户的视角,相机在哪用户就只能看到场景中的哪一个部分)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)

创建Camera也是new一个对象。这里创建的PerspectiveCamera(透视相机),意思是视角是“透视”的,“透视”原词就是绘画的专业术语。原理类似这样:

透视相机

ThreeJS中的camera有几个类型,可以按需求创建。目前先不要关心其他的camera类型

创建物体

1
2
3
4
5
// 创建物体——立方体
const geometry = new THREE.BoxGeometry() // 几何体数据
const material = new THREE.MeshBasicMaterial({ color: '#00ff00' }) // 材质
const cube = new THREE.Mesh(geometry, material) // 网格物体
scene.add(cube)

物体由几何数据geometry和材质material

  • 几何数据表现物体的形状大小
  • 材质表示物体表面颜色

在展示的3d场景中,物体被的抽象为网格物体Mesh。即Mesh包含了物体的几何数据和材质

创建渲染器

1
2
3
4
// 创建渲染器(绘制场景)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

动画

1
2
3
4
5
6
7
8
9
10
function animate() {
cube.rotation.x += 0.01
cube.rotation.y += 0.01

renderer.render(scene, camera)
requestAnimationFrame(animate)
}


animate()

和视频的的原理是一样的,每一次渲染都只是渲染一个固定的视角,固定的物体,画面静止。快速渲染多个画面,画面就有了动态。这里也一样,也是一个一个画面渲染,所以要手动执行动画不断渲染。动画使用requestAnimationFrame

即three和普通的canvas一样,绘制是一帧一帧的,要动画,请不断绘制。

详细代码

纹理加载

纹理加载

和第一个demo基本一样,但多使用了一个纹理。什么是纹理?请看专业回答,也即纹理是材质的一种,记录位置和颜色等信息,更白话一点,大概就是“皮肤”。ThreeJS可以通过加载一张图片为纹理。

这个例子来做一个立方体木块,嗯。。。大概就像游戏 我的世界 的木块一样。代码基本和第一个demo一样,唯一不同是,创建物体阶段,多加了纹理加载和处理

1
2
3
4
5
6
7
8
9
10
11
12
13
// 纹理加载
// 生成一个纹理加载器
const textureLoader = new THREE.TextureLoader()
// 加载器加载具体的纹理
const texture = textureLoader.load("../textures/wood.jpg")

// 创建物体——立方体
// 物体由几何数据geometry和材质material,几何数据表现物体的形状大小,材质表示物体表面颜色
// 在展示的3d场景中,物体被的抽象为网格物体Mesh。即Mesh包含了物体的几何数据和材质
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial({ map: texture })
const cube = new THREE.Mesh(geometry, material)
scene.add(cube)

详细代码

还有比较常见的例子,球体然后加载一个对应地图的纹理,构成一个地球。有点逐步创造世界的感觉了

视角控制

视角控制

所谓视角控制,即用户可以使用鼠标或者键盘来控制相机,使得“看”到的画面不一样,可变化性,使得这个世界更加真实,控制性则大概就是这个世界存在的意义。

物体足够真实,视角控制又可以让用户从各个角度去观察,这大概就是很多展览业务要做的事情。现在配合前面的例子,实现一个预览木块(。。。);同样基本结构也和前demo一样,但多加一些视角控制逻辑

1
2
3
4
5
6
7
8
9
// 轨道控制器,Orbit controls(轨道控制器)可以使得相机围绕目标进行轨道运动。
controls = new OrbitControls(camera, renderer.domElement)
controls.update()

function animate(){
// some code
controls.update()
requestAnimationFrame(animate)
}

是的,就是这么简单。。。

当然,这里只是使用了一种控制器,轨道控制器,相机随着指定目标旋转、缩放,即相机移动的位置是一个球面,或者接近/原理球心。

详细代码

导入模型

导入模型

ThreeJS中提供了创建物体的各种基础方法,但是要创建一个复杂的物体,比如比较精细的人体时,使用代码创建会非常的复杂。所以最好是能够使用其他一些图形化工具创建好对应的物体然后导入使用。那么还要开发一个这样的工具?答案肯定是否定(虽然要开发也不是我开发),目前已经有很多3D游戏或者建模影视作品了,工具很早就是已经有了的。比如3dmax,Maya等,建好模型导出一定的格式,再由ThreeJS解析,最后再搭配js逻辑即可。

1
2
3
4
5
6
// gltf物体加载器(用于加载建模后导出的文件gltf)
const gltfLoader = new GLTFLoader();
// 加载模型
const sub = gltfLoader.load('../gltf/Soldier.glb', (sub) => {
scene.add(sub.scene)
})

既然已经站在巨人的肩膀了,所以代码很简单,加载器,加载,加载完成后回调添加到场景scene中,完成。

不过这里,有一点问题。创建的3d世界和现实世界是很像的,人要看到物体,需要有光。没有光,无法对物体进行反射,最终物体就是黑色的。所以只加上面代码,显示出来的模型是黑色的,若scene的背景色没有设置,默认为黑色,那么最终全屏都是黑色的。下面来为世界加上一点光

1
2
3
4
5
// 灯光
// 环境灯光,光辉会到达世界的每一个角落。。。。(所有地方都有光,不能制造阴影)
// 没有灯光之前,导入的物体是全黑的。因为没有光照到物体上,人是无法看到任何颜色的。
const light = new THREE.AmbientLight(); // soft white light
scene.add(light);

嗯,我说要有光,世界就有了光

详细代码,这里要注意,直接访问本地的html文件(即浏览器地址栏是file://开头的)加载的会有跨域问题。要启动一个服务器,目录中threeJS已经写好了服务器代码,可以直接启动,详细看threeJS下的README文件

动画、光线和阴影

动作、阴影

骨架与动作

如何让计算机理解动作?

  • 观察人摇头,可以发现手、脚、躯体都没有动,只有头部移动了,而且把头部看作很多个小点,小点都以一条轴做旋转。
  • 观察人挥手,可以发现脚、躯体、头都没有动,只有手腕部动了,同样把手腕部分看作很多个小点,小点都以手肘中心点做旋转

按照这个规律,前辈们就把一个物体切分多个部分,将作相同运动的化为一部分,这样在修改计算机中每个点数据时就是某一部分的点都进行一定的相同的操作。更深入的,物体划分处理的部分都可以看作一条线——称为骨骼,这条线作什么操作其他的点就作什么操作,比如手部分抽象出来的骨骼y轴平移了10个单位,那个整个手的点都y轴平移10个单位即可完成整个手的动作,旋转等操作也基本类似。这样,物体的运动就可以抽象成一堆线段——称为骨骼的运动。由此复杂的物体转换成简单的线段运动(大佬们nb!!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// gltf物体加载器(用于加载建模后导出的文件gltf)
const gltfLoader = new GLTFLoader();
// 加载模型
gltfLoader.load('../gltf/Soldier.glb', (sub) => {
// 取得模型
const model = sub.scene

// 模型放到场景中
scene.add(sub.scene)

// 取得骨架skeleton
const skeleton = new THREE.SkeletonHelper(model);
// skeleton.visible = true;
skeleton.visible = false;
scene.add(skeleton);
})

在ThreeJS中,骨架skeleton是由骨骼bone组成的,整个物体被骨骼分为多个部分,一个骨骼对应物体的某一部分。骨骼其实就是一条线段,为了简化物体的运动而抽象出来的,将三维立体的运动简化为了线段的运动。和现实一样,当物体运动时,运动的是骨骼,骨骼带动了物体某一部分

骨骼也由建模时完成。

动作

所谓动作也是一个动画,动画由多个静止的画面(帧)组成。骨骼在每一帧中偏移选转,多帧组合就形成了运动。

同样的,ThreeJS代码中抽象也分了几个部分:

  1. 每一帧中,每个骨骼都会有各自的运动,它们的集合成keyframe

  2. 骨骼多帧按时间顺序组成的集合(动画),存储在一个称为 关键帧轨道keyframeTrack 中。手臂的运动,手腕的运动等

  3. 通过不同的keyframeTrack组合,可以有多种运动(动画)。步行/奔跑/跳跃等。

最后所有的动画数据都存放在animations里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 时钟
clock = new THREE.Clock();



// gltf物体加载器(用于加载建模后导出的文件gltf)
const gltfLoader = new GLTFLoader();
// 加载模型
gltfLoader.load('../gltf/Soldier.glb', (sub) => {
// 取得模型
const model = sub.scene

// 动作
const animations = sub.animations;

// 剪辑动画是一些运动的数据,而运动播放操作是通过 混合器mixer来进行的。
mixer = new THREE.AnimationMixer(model);

// mixter.clipAction(animation)接收动作动画数据,并生成剪辑动画(clipAction)
const idleAction = mixer.clipAction(animations[0]);
const walkAction = mixer.clipAction(animations[3]);
const runAction = mixer.clipAction(animations[1]);

const actions = [idleAction, walkAction, runAction];

// 动画播放
runAction.play()
})



function animate() {
// camera.position.z += 0.1
controls.update()

// 更新每一帧动作
if (mixer) {
mixer.update(clock.getDelta())
}

renderer.render(scene, camera)
requestAnimationFrame(animate)
}

光线和阴影

现在为了更加的真实,加上一个阴影效果。ThreeJS中添加阴影有三步

  1. 添加光线
  2. 设置被投影物体
  3. 设置产生投影物体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 1.添加灯光
// 现在使用一下贴合现实的直射光,直射光发出平行光可以产生阴影(shadow)
const dirLight = new THREE.DirectionalLight(0xffffff);
dirLight.position.set(- 3, 2, - 10);
dirLight.castShadow = true;
// 设置 正交相机(OrthographicCamera) 相关属性,即阴影可以到达的大小
dirLight.shadow.camera.top = 2;
dirLight.shadow.camera.bottom = - 2;
dirLight.shadow.camera.left = - 2;
dirLight.shadow.camera.right = 2;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 40;
scene.add(dirLight);


// 2.设置被投影物体
// 创建物体——平地
const geometry = new THREE.PlaneGeometry(100, 100);
// const material = new THREE.MeshBasicMaterial({ color: 0xeeeeee, side: THREE.DoubleSide });
const material = new THREE.MeshPhongMaterial( { color: 0x999999, depthWrite: false } )
const plane = new THREE.Mesh(geometry, material);
plane.receiveShadow = true; // 接受投影
plane.rotateX(-Math.PI / 2)
scene.add(plane);


// gltf物体加载器(用于加载建模后导出的文件gltf)
const gltfLoader = new GLTFLoader();
// 加载模型
gltfLoader.load('../gltf/Soldier.glb', (sub) => {
// 取得模型
const model = sub.scene

// 3.设置产生投影物体
model.traverse(function (object) {

if (object.isMesh) object.castShadow = true; //可被投影

});

})


ThreeJS里面的阴影是用camera计算出来的,其实就是投影计算。

随记

  • Camera相机,透视相机和正交相机逻辑

    正交相机

  • 有些材质是不能产生阴影的比如MeshPhongMaterial

详细代码

参考

three.js官网

3现代计算机图形学(正交投影,透视投影,MVP变换)

https://www.zhihu.com/question/25745472

three.js 自制骨骼动画(二)

最后更新: 2021年08月09日 23:15

原始链接: https://idkhts.github.io/2021/08/08/three-js%E5%AD%A6%E4%B9%A0/