【可视化学习】90-无限管/隧道效果
发表于:2024-09-26 |

前言

本篇文章用threejs带大家实现一下无限隧道的效果。

实现逻辑

首先,我们要知道,如何才能无限,当然就是圆,而要实现隧道效果,自然就是需要的是立体的圆环,而我们的视角要在圆环里面。

初始化项目

ok,在知道了这个实现逻辑之后,我们先去初始化threejs项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<template>
<div></div>
</template>

<script setup>
import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 1.创建场景
const scene = new THREE.Scene();

// 2.创建相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);

// 设置相机位置
camera.position.set(0, 0, 10);

// 添加相机
scene.add(camera);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();

//设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);

// 将webgl渲染的canvas内容添加到body上
document.body.appendChild(renderer.domElement);

//当前是平面的,让平面立体起来,使用控制器controls
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 持续渲染,让相机场景动起来
function render() {
controls.update();
// 使用渲染器通过相机将场景渲染进来
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

// 监听画面的变化,更新渲染的画面
window.addEventListener("resize", () => {
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
</script>

<style>
* {
margin: 0;
padding: 0;
}
canvas {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
</style>

添加管道

我们可以看到,threejs中的管道比较符合我们的需求。
效果图

那我们先添加一个管道,下面这段是官网示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 添加管道
class CustomSinCurve extends THREE.Curve {
constructor(scale = 1) {
super();
this.scale = scale;
}

getPoint(t, optionalTarget = new THREE.Vector3()) {
const tx = t * 3 - 1.5;
const ty = Math.sin(2 * Math.PI * t);
const tz = 0;

return optionalTarget.set(tx, ty, tz).multiplyScalar(this.scale);
}
}
const path = new CustomSinCurve(10);
const geometry = new THREE.TubeGeometry(path, 20, 2, 8, false);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

我们可以看到,管道已经被我添加进来了
效果图

将管道变成圆环

我们可以看到管道的获取点的函数,我们完全可以把这个getPoint改成我们自己的方法

原来的点位布置可以想象为,x方向一直变大,y方向按照sin曲线忽高忽低,z轴一直为0。可以简单理解为xy平面上的正弦运动。

我们将getPoint修改成为这样

1
2
3
const tx = Math.cos(2 * Math.PI * t);
const ty = Math.sin(2 * Math.PI * t);
const tz = 0.1 * Math.sin(8 * Math.PI * t);

这样的点位布置,xy平面上是一个圆,因为cos和sin的平方加起来是一个1,你可以理解,sin函数为1的时候cos为0,cos为1的时候sin为0,z方向设置了一个值,用来添加抖动。

然后我就得到了这样的一个立体圆环带一点点弧度的管道。这里我旋转了一下,为了大家方便看到z方向的变化。

效果图

添加一张贴图

贴图

这里添加完成纹理之后设置了wrapS和wrapT,让纹理在水平和垂直方向上进行重复,水平重复10次,垂直1次

1
2
3
4
5
6
7
const material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
map: new THREE.TextureLoader().load("/texture/texture.png"),
});
material.map.wrapS = THREE.RepeatWrapping
material.map.wrapT= THREE.RepeatWrapping
material.map.repeat.set(10, 1)

这样就得到了这样的一个管道

效果图

将视角移动到管道内

要实现穿越效果,我们要将摄像机放在模型内部,并沿着隧道路径运动
我们先实现视角放进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 获得初始点位的三维向量
const startPoint = path.getPoint(0);
// 根据实际情况调整偏移量
const offsetVector = new THREE.Vector3(0,4, 1);
camera.position.copy(startPoint).add(offsetVector);
// 持续渲染,让相机场景动起来
function render() {
controls.update();
// 让相机始终看向隧道路径上的当前位置
camera.lookAt(startPoint);
// 使用渲染器通过相机将场景渲染进来
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

这里我的偏移三维向量(0,4,1)是基于我设置的参数进行设置的,你可以慢慢调整,直到你的视角在隧道内部就行。
效果图

添加运动效果

然后,我们添加运动效果,我们这时候就需要思考一下了,这个该如何实现,很明显就是我们需要根据t计算当前需要看的点以及不停调整偏移量

得到看的点

这个是简单的,我们设置time和speed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let time=0;
let speed=0.02;

function render() {
controls.update();
time+=speed
// 获得初始点位的三维向量
const startPoint = path.getPoint(time);
camera.position.copy(startPoint)
camera.lookAt(startPoint);
// 使用渲染器通过相机将场景渲染进来
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

这时候得到的效果肯定是有问题的,因为我们没设置偏移量,因为我们把相机放到了构建管道的点上面,又让它看向这个点,也就是自己看到自己,这显然是不现实的。

偏移量思路分析

那这个偏移量该如何计算呢,其实我们可以换一个思路,我们其实就是想一直看到下一个点而已,那我们完全可以把相机放到前一个点位,然后看向下一个点位

实现代码

代码里面我都加了注释,大家自己理解一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
let time = 0;
let binormal = new THREE.Vector3();
let normal = new THREE.Vector3();
let speed =10;

function render() {
controls.update();
time += speed;
// 定义了一个循环一圈所需的时间,这里设置为 20 秒(20 * 1000 毫秒)。
let looptime = 20 * 1000;
// 计算当前时间在一个循环周期中的比例。通过对总时间取模looptime,确保时间始终在一个循环周期内,然后除以looptime得到比例值t,这个值在 0 到 1 之间变化。
let t = (time % looptime) / looptime;
// 根据时间比例t,从管道的路径上获取当前时间对应的位置点。这样随着时间的变化,位置点也会在管道路径上不断移动。
let pos = geometry.parameters.path.getPointAt(t);
// 获取管道的切线数量,这与管道的复杂度或细分程度有关。
let segments = geometry.tangents.length;
// 通过时间比例t乘以切线数量得到一个值pickt,然后取整得到当前时间对应的切线索引pick
let pickt = t * segments;
let pick = Math.floor(pickt);
// 计算下一个切线索引,通过对当前切线索引加 1 并取模切线数量,确保索引在合法范围内循环。
let pickNext = (pick + 1) % segments;
// 计算下一个点的法向向量和当前点法向向量的差值,存储在binormal中。
binormal.subVectors(geometry.binormals[pickNext], geometry.binormals[pick]);
// 处理小数部分的误差。将差值向量binormal乘以小数部分(pickt - pick),然后加上当前点的法向向量,得到更准确的法向向量。
binormal.multiplyScalar(pickt - pick).add(geometry.binormals[pick]);
// 获取当前时间点在管道路径上的切线向量。
let dir = geometry.parameters.path.getTangentAt(t);
// 通过当前和下一个点位差值的法向向量(binormal)与切线向量dir的叉乘,得到相机的上方向向量normal。
normal.copy(binormal).cross(dir);
// 设置相机的位置为当前时间在管道路径上的位置点。
camera.position.copy(pos);
// 计算相机的目标点,通过在当前时间比例的基础上稍微增加一点(这里是加上1 / geometry.parameters.path.getLength),并取模 1,确保目标点在管道路径上循环。
let lookAt = geometry.parameters.path.getPointAt(
(t + 1 / geometry.parameters.path.getLength()) % 1
);
// 设置相机的矩阵,使其从当前位置看向目标点,并使用计算得到的上方向向量。
// 视点、目标点、上方向得到旋转矩阵
camera.matrix.lookAt(camera.position, lookAt, normal);
// 然后,根据矩阵设置相机的旋转,确保相机正确地指向目标点并具有正确的方向。
camera.rotation.setFromRotationMatrix(camera.matrix, camera.rotation.order);

// 使用渲染器通过相机将场景渲染进来
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

这个效果当然你可以用threejs自带的沿曲线运动实现,但是在弯道的部分效果过渡没有现在按照自己实现的丝滑。

给大家看看实现的效果
效果图

实现滚轮控制速度

这里我们不是有个speed吗,我们完全可以添加一个滚轮事件

1
2
3
document.addEventListener('wheel', e => {
speed += e.deltaY * 0.02
})

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
<template>
<div></div>
</template>

<script setup>
import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// 1.创建场景
const scene = new THREE.Scene();

// 2.创建相机
const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);

// 设置相机位置
camera.position.set(0, 0, 150);

// 添加相机
scene.add(camera);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();

//设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);

// 将webgl渲染的canvas内容添加到body上
document.body.appendChild(renderer.domElement);

//当前是平面的,让平面立体起来,使用控制器controls
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 添加管道
class CustomSinCurve extends THREE.Curve {
constructor(scale = 1) {
super();
this.scale = scale;
}

getPoint(t, optionalTarget = new THREE.Vector3()) {
const tx = Math.cos(2 * Math.PI * t);
const ty = Math.sin(2 * Math.PI * t);
const tz = 0.1 * Math.sin(8 * Math.PI * t);

return optionalTarget.set(tx, ty, tz).multiplyScalar(this.scale);
}
}
const path = new CustomSinCurve(40);
const geometry = new THREE.TubeGeometry(path, 200, 1, 8, false);
const material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide,
map: new THREE.TextureLoader().load("/texture/texture.png"),
});
// 纹理重复
material.map.wrapS = THREE.RepeatWrapping;
material.map.wrapT = THREE.RepeatWrapping;
material.map.repeat.set(10, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

let time = 0;
let binormal = new THREE.Vector3();
let normal = new THREE.Vector3();
let speed =10;

function render() {
controls.update();
time += speed;
// 定义了一个循环一圈所需的时间,这里设置为 20 秒(20 * 1000 毫秒)。
let looptime = 20 * 1000;
// 计算当前时间在一个循环周期中的比例。通过对总时间取模looptime,确保时间始终在一个循环周期内,然后除以looptime得到比例值t,这个值在 0 到 1 之间变化。
let t = (time % looptime) / looptime;
// 根据时间比例t,从管道的路径上获取当前时间对应的位置点。这样随着时间的变化,位置点也会在管道路径上不断移动。
let pos = geometry.parameters.path.getPointAt(t);
// 获取管道的切线数量,这与管道的复杂度或细分程度有关。
let segments = geometry.tangents.length;
// 通过时间比例t乘以切线数量得到一个值pickt,然后取整得到当前时间对应的切线索引pick
let pickt = t * segments;
let pick = Math.floor(pickt);
// 计算下一个切线索引,通过对当前切线索引加 1 并取模切线数量,确保索引在合法范围内循环。
let pickNext = (pick + 1) % segments;
// 计算下一个点的法向向量和当前点法向向量的差值,存储在binormal中。
binormal.subVectors(geometry.binormals[pickNext], geometry.binormals[pick]);
// 处理小数部分的误差。将差值向量binormal乘以小数部分(pickt - pick),然后加上当前点的法向向量,得到更准确的法向向量。
binormal.multiplyScalar(pickt - pick).add(geometry.binormals[pick]);
// 获取当前时间点在管道路径上的切线向量。
let dir = geometry.parameters.path.getTangentAt(t);
// 通过当前和下一个点位差值的法向向量(binormal)与切线向量dir的叉乘,得到相机的上方向向量normal。
normal.copy(binormal).cross(dir);
// 设置相机的位置为当前时间在管道路径上的位置点。
camera.position.copy(pos);
// 计算相机的目标点,通过在当前时间比例的基础上稍微增加一点(这里是加上1 / geometry.parameters.path.getLength),并取模 1,确保目标点在管道路径上循环。
let lookAt = geometry.parameters.path.getPointAt(
(t + 1 / geometry.parameters.path.getLength()) % 1
);
// 设置相机的矩阵,使其从当前位置看向目标点,并使用计算得到的上方向向量。
// 视点、目标点、上方向得到旋转矩阵
camera.matrix.lookAt(camera.position, lookAt, normal);
// 然后,根据矩阵设置相机的旋转,确保相机正确地指向目标点并具有正确的方向。
camera.rotation.setFromRotationMatrix(camera.matrix, camera.rotation.order);

// 使用渲染器通过相机将场景渲染进来
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}

render();

// 监听画面的变化,更新渲染的画面
window.addEventListener("resize", () => {
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});

document.addEventListener('wheel', e => {
speed += e.deltaY * 0.02
})
</script>

<style>
* {
margin: 0;
padding: 0;
}
canvas {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}
</style>

结语

通过这个管道,我们可以实现其他很多类似的效果,比如血管等等之类的。本篇文章就到这里了,更多内容敬请期待,债见~

上一篇:
【可视化学习】91-从入门到放弃WebGL(二十)
下一篇:
【可视化学习】89-从入门到放弃WebGL(十九)