文章同步更新于我的个人博客:松果猿的博客,欢迎访问获取更多技术分享。

同时,您也可以关注我的微信公众号:松果猿的代码工坊,获取最新文章推送和编程技巧。

记录本人three.js的学习之路

前言

通过前两期关于three.js的文章,我们已经对three.js有了比较清晰的认识。下面跟着我制作一个three.js的简易版我的世界。

既然是简易版,下面是这个Minecraft必须要有的几个功能:

  1. 可以放置方块和破坏方块
  2. 前后左右移动跳跃
  3. 有简单的UI框,可以选择放置方块类型

当然我也是初学者,制作过程遇到了两个比较困难的点:

  1. 如何计算放置方块的位置
  2. 人物和方块的碰撞检测

这两个点我后面都会给出我的解决方法

演示:https://www.bilibili.com/video/BV112z3YUEmj

仓库:https://github.com/songguo1/ThreeJS-Learning

初始化项目

使用 Vite 创建一个新的项目,选择 “Vanilla” 模板。

将多余的文件都删去,文件目录为:

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
Three_Minecraft/
├── index.html
├── package.json
├── vite.config.js
├── public/
│ └── textures/
│ ├── blocks/
│ │ ├── grass.png
│ │ ├── dirt.png
│ │ └── stone.png
│ └── skybox/
│ ├── right.png
│ ├── left.png
│ ├── top.png
│ ├── bottom.png
│ ├── front.png
│ └── back.png
└── src/
├── main.js # 主入口文件
├── scene.js # 场景相关
├── player.js # 玩家控制相关
├── physics.js # 物理检测相关
├── events.js # 事件处理相关
├── blocks/
├── blockSystem.js # 方块系统
└── blockTypes.js # 方块类型定义

修改index.html,创建方块选择界面和十字光标

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Minecraft Clone</title>
<style>
body { margin: 0; }
#ui {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.5);
padding: 10px;
border-radius: 5px;
display: flex;
gap: 10px;
}
.block-option {
width: 40px;
height: 40px;
background-size: cover;
cursor: pointer;
border: 2px solid transparent;
}
.block-option.selected {
border-color: white;
}
#crosshair {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
display: block;
width: 20px;
height: 20px;
}
#crosshair::before,
#crosshair::after {
content: '';
position: absolute;
background: white;
opacity: 0.8;
}
#crosshair::before {
width: 2px;
height: 20px;
left: 50%;
transform: translateX(-50%);
}
#crosshair::after {
width: 20px;
height: 2px;
top: 50%;
transform: translateY(-50%);
}
</style>
</head>
<body>
<div id="crosshair"></div>
<div id="ui">
<div class="block-option" data-type="grass"></div>
<div class="block-option" data-type="dirt"></div>
<div class="block-option" data-type="stone"></div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

方块

blockTypes.js中添加如下代码,可见方块类型有草块、土块、石块三种类型:

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
import * as THREE from 'three';

//===方块类型===
export class BlockType {
static GRASS = 'grass';
static DIRT = 'dirt';
static STONE = 'stone';
}

//===方块管理器===
export class BlockManager {
constructor() {
this.blocks = new Map();
this.textureLoader = new THREE.TextureLoader();

const grassTexture = this.textureLoader.load('/textures/blocks/grass.png');
const dirtTexture = this.textureLoader.load('/textures/blocks/dirt.png');
const stoneTexture = this.textureLoader.load('/textures/blocks/stone.png');

this.materials = {
[BlockType.GRASS]: new THREE.MeshStandardMaterial({ map: grassTexture }),
[BlockType.DIRT]: new THREE.MeshStandardMaterial({ map: dirtTexture }),
[BlockType.STONE]: new THREE.MeshStandardMaterial({ map: stoneTexture })
};

this.geometry = new THREE.BoxGeometry(1, 1, 1);
}

createBlock(type, position) {
const block = new THREE.Mesh(this.geometry, this.materials[type]);
block.position.copy(position);
block.userData.type = type;
return block;
}
}

注意这里的纹理贴图文件需要存储在根目录public下,一开始我将图片放在src/static/texture目录下,THREE.TextureLoader()使用相对路径加载不成功,在网上找到了解决方法:Vue3 THREE.TextureLoader()加载图片失败_vue3 textureloader().load-CSDN博客

blockSystem.js添加如下代码,创建一个初始地面以及更新方块事件:

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
//===方块系统===

import { BlockType, BlockManager } from "./blocks";
import * as THREE from "three";

export function initBlockSystem(scene) {
const blockManager = new BlockManager();

// 创建初始地面
for (let x = -10; x <= 10; x++) {
for (let z = -10; z <= 10; z++) {
const block = blockManager.createBlock(
BlockType.GRASS,
new THREE.Vector3(x, -1, z)
);
scene.add(block);
}
}

return {
blockManager,
selectedBlockType: BlockType.GRASS,
updateBlockSelection
};
}
// 更新选中的方块类型
function updateBlockSelection(type) {
document.querySelectorAll(".block-option").forEach((opt) => {
opt.classList.remove("selected");
if (opt.dataset.type === type) {
opt.classList.add("selected");
}
});
}

人物

毕竟是第一人称,控件肯定不能是 OrbitControls,而应该是 PointerLockControls:

players添加如下代码,初始化玩家控制器和状态:

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
//===玩家===

import * as THREE from "three";
import { PointerLockControls } from "three/examples/jsm/controls/PointerLockControls";

export function initPlayer(camera, element) {
const controls = new PointerLockControls(camera, element);

const playerState = {
velocity: new THREE.Vector3(),
moveForward: false,
moveBackward: false,
moveLeft: false,
moveRight: false,
canJump: true,
frontCollide: false,
backCollide: false,
leftCollide: false,
rightCollide: false,
onGround: false,
height: 2,
width: 1,
speed: 5.0,
boundingBox: new THREE.Box3()
};

return { controls, playerState };
}

场景

scene.js中添加如下代码,向场景中添加一个天空盒,以及环境光AmbientLight和平行光DirectionalLight

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
//===场景===
import * as THREE from "three";

export function initScene() {
const scene = new THREE.Scene();

// 设置天空盒
const skyboxTextures = new THREE.CubeTextureLoader().load([
"/textures/skybox/right.png",
"/textures/skybox/left.png",
"/textures/skybox/top.png",
"/textures/skybox/bottom.png",
"/textures/skybox/front.png",
"/textures/skybox/back.png",
]);
scene.background = skyboxTextures;

// 添加光源
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6);
directionalLight.position.set(10, 20, 0);
scene.add(directionalLight);

return scene;
}

放置和破坏方块

这里就碰到了我的第一个难点:如何找到放置方块的位置?

选中场景中的某个方块一般来说都是通过射线拾取方法:

通过屏幕中心射出一条射线获取到交互的对象属性,破坏方块简单,直接将交互对象删去就行,放置方块就比较复杂。

玩过Minecraft都知道,如果想要放置方块,点击最近的相邻的方块面就可以放置,那我们通过射线拾取的交互对象的坐标计算出需要放置的坐标就可以了,我们先看一下three.js射线交互可以获取到哪些属性:

当然一开始我根本没注意到object这个属性,我首先注意到的是相交部分的点point和相交的面face以及内插法向量,我想直接通过点或者面的中心点加上这个内插法向量再四舍五入一下不就得到了方块的中心点吗(我们初始场景的方块的中心点都是整数,方便后面计算)

然后就有了下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.....
raycaster.setFromCamera(mouse, camera);
// 检测与场景中对象的交互
const intersects = raycaster.intersectObjects(scene.children);

// 如果有交互对象
if (intersects.length > 0) {
const intersect = intersects[0];
const point = intersect.point;

// 右键点击:创建一个块
if (event.button === 2) {
const position = point.add(intersect.face.normal);
// 将位置四舍五入到整数,确保方块对齐到网格
position.x = Math.round(point.x);
position.y = Math.round(point.y);
position.z = Math.round(point.z);
........

似乎不太行,因为总是会有防置不了方块的情况,而且还是特定方向放不了。

经过调试纠错,我想大概是四舍五入的问题,因为不管射线与哪个方块的面交互,交互点的坐标的xyz总有一个小数部分是0.5(方块中心点是整数,方块边长是1),如果小数部分是0.5,则不能按照常规的四舍五入方法,这样就会隔一个方块放置:

1
2
3
4
5
6
7
function customRound(value) {
const decimal = Math.abs(value) % 1;
if (decimal === 0.5) {
return Math.floor(value);
}
return Math.round(value);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.....
raycaster.setFromCamera(mouse, camera);
// 检测与场景中对象的交互
const intersects = raycaster.intersectObjects(scene.children);

// 如果有交互对象
if (intersects.length > 0) {
const intersect = intersects[0];
const point = intersect.point;

// 右键点击:创建一个块
if (event.button === 2) {
const position = point.add(intersect.face.normal);
// 将位置四舍五入到整数,确保方块对齐到网格
position.x = customRound(point.x);
position.y = customRound(point.y);
position.z = customRound(point.z);
........

还是没解决,我又想是不是 js 精度的问题,众所周知在 javascript 中0.1 + 0.2 == 0.3的结果实际上是 false,一番折腾后还是不对;然后又想是不是要考虑正负号的情况….

随后偶然看到了交互属性里有object,而且也有中心点,那直接将中心点加上内插值向量不就可以了!

如下所示:

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
function handleClick(event, controls, scene, camera, blockSystem, playerState) {
if (!controls.isLocked) {
controls.lock();
document.getElementById("crosshair").style.display = "block";
return;
}
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(
(window.innerWidth / 2 / window.innerWidth) * 2 - 1,
(-window.innerHeight / 2 / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
const intersect = intersects[0];
if (event.button === 2) {
// 右键放置方块
const position = intersect.object.position
.clone()
.add(intersect.face.normal);
const blockBox = new THREE.Box3();
blockBox.setFromCenterAndSize(position, new THREE.Vector3(1, 1, 1));
playerState.boundingBox.setFromCenterAndSize(
camera.position,
new THREE.Vector3(
playerState.width * 0.8,
playerState.height,
playerState.width * 0.8
)
);
if (!blockBox.intersectsBox(playerState.boundingBox)) {
const block = blockSystem.blockManager.createBlock(
blockSystem.selectedBlockType,
position
);
scene.add(block);
}
} else if (event.button === 0) {
// 左键破坏方块
scene.remove(intersect.object);
}
}
}

碰撞检测

这个是第二个难点,花了我许多时间,就是没有非常好的方法,要不然就是人物卡住不能动,要不然就是一碰撞到物体就“吸附”在上面

。然后上网上找解决方法,直到我看到这么一篇博客:https://www.cnblogs.com/sincw/p/9161922.html,这个问题才迎刃而解:

physics.js中添加碰撞检测逻辑代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//===射线检测===
import * as THREE from "three";

// 检测射线是否与场景中的物体发生碰撞
export function checkCollision(origin, direction, scene, distance) {
const raycaster = new THREE.Raycaster();
raycaster.set(origin, direction.normalize());
const intersects = raycaster.intersectObjects(scene.children).filter(intersect =>
Math.abs(intersect.object.position.y - origin.y) < 1
);
return intersects.length > 0 && intersects[0].distance <= distance;
}
// 检测射线是否与地面发生碰撞
export function checkGroundCollision(origin, scene) {
const raycaster = new THREE.Raycaster();
const direction = new THREE.Vector3(0, -1, 0);
raycaster.set(origin, direction);
const intersects = raycaster.intersectObjects(scene.children).filter(intersect =>
intersect.object.position.y < origin.y
);
return intersects.length > 0 && intersects[0].distance <= 0.2;
}

player.js中添加移动逻辑方法:

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
export function handleMovement(delta, playerState, controls, scene, camera) {
const cameraAngle = getCameraDirection(camera);

// 地面检测
const feetPosition = camera.position.clone();
feetPosition.y -= playerState.height / 2 - 0.1;
playerState.onGround = checkGroundCollision(feetPosition, scene);

// 重力和跳跃
if (!playerState.onGround) {
playerState.velocity.y -= 20 * delta;
} else if (playerState.velocity.y <= 0) {
playerState.velocity.y = 0;
playerState.canJump = true;
}

camera.position.y += playerState.velocity.y * delta;

// 移动碰撞检测和移动
handleCollisionAndMovement(playerState, controls, scene, feetPosition, cameraAngle, delta);

// 防止掉出地图
if (camera.position.y < -10) {
camera.position.y = 10;
playerState.velocity.y = 0;
}
}

// 获取相机方向
function getCameraDirection(camera) {
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
return Math.atan2(direction.x, direction.z);
}

// 获取移动方向
function getMovementDirection(angle, moveType) {
const direction = new THREE.Vector3();
switch(moveType) {
case "forward":
direction.set(Math.sin(angle), 0, Math.cos(angle));
break;
case "backward":
direction.set(-Math.sin(angle), 0, -Math.cos(angle));
break;
case "left":
direction.set(Math.cos(angle), 0, -Math.sin(angle));
break;
case "right":
direction.set(-Math.cos(angle), 0, Math.sin(angle));
break;
}
return direction;
}

// 处理碰撞和移动
function handleCollisionAndMovement(playerState, controls, scene, feetPosition, cameraAngle, delta) {
const { moveForward, moveBackward, moveLeft, moveRight, speed } = playerState;

if (moveForward) {
const direction = getMovementDirection(cameraAngle, 'forward');
if (!checkCollision(feetPosition, direction, scene, 0.3)) {
controls.moveForward(speed * delta);
}
}

if (moveBackward) {
const direction = getMovementDirection(cameraAngle, 'backward');
if (!checkCollision(feetPosition, direction, scene, 0.3)) {
controls.moveForward(-speed * delta);
}
}

if (moveLeft) {
const direction = getMovementDirection(cameraAngle, 'left');
if (!checkCollision(feetPosition, direction, scene, 0.3)) {
controls.moveRight(-speed * delta);
}
}

if (moveRight) {
const direction = getMovementDirection(cameraAngle, 'right');
if (!checkCollision(feetPosition, direction, scene, 0.3)) {
controls.moveRight(speed * delta);
}
}
}

结语

下面我们将所有代码补充完整:

events.js,监听鼠标点击事件

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
//===事件===

import * as THREE from "three";

export function initEventListeners(
controls,
scene,
camera,
blockSystem,
playerState
) {
// UI事件
initBlockSelectionUI(blockSystem);

// 鼠标事件
document.addEventListener("click", (event) =>
handleClick(event, controls, scene, camera, blockSystem, playerState)
);

// 键盘事件
document.addEventListener("keydown", (event) =>
handleKeyDown(event, playerState, blockSystem)
);
document.addEventListener("keyup", (event) =>
handleKeyUp(event, playerState)
);
}

// 初始化方块选择UI
function initBlockSelectionUI(blockSystem) {
document.querySelectorAll(".block-option").forEach((option) => {
const type = option.dataset.type;
option.style.backgroundImage = `url(/textures/blocks/${type}.png)`;
option.addEventListener("click", () => {
blockSystem.selectedBlockType = type;
blockSystem.updateBlockSelection(type);
});
});
}
// 鼠标点击事件
function handleClick(event, controls, scene, camera, blockSystem, playerState) {
if (!controls.isLocked) {
controls.lock();
document.getElementById("crosshair").style.display = "block";
return;
}
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(
(window.innerWidth / 2 / window.innerWidth) * 2 - 1,
(-window.innerHeight / 2 / window.innerHeight) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
const intersect = intersects[0];
if (event.button === 2) {
// 右键放置方块
const position = intersect.object.position
.clone()
.add(intersect.face.normal);
const blockBox = new THREE.Box3();
blockBox.setFromCenterAndSize(position, new THREE.Vector3(1, 1, 1));
playerState.boundingBox.setFromCenterAndSize(
camera.position,
new THREE.Vector3(
playerState.width * 0.8,
playerState.height,
playerState.width * 0.8
)
);
if (!blockBox.intersectsBox(playerState.boundingBox)) {
const block = blockSystem.blockManager.createBlock(
blockSystem.selectedBlockType,
position
);
scene.add(block);
}
} else if (event.button === 0) {
// 左键破坏方块
scene.remove(intersect.object);
}
}
}

// 键盘按下事件
function handleKeyDown(event, playerState, blockSystem) {
switch (event.code) {
case "ArrowUp":
case "KeyW":
playerState.moveForward = true;
break;
case "ArrowDown":
case "KeyS":
playerState.moveBackward = true;
break;
case "ArrowLeft":
case "KeyA":
playerState.moveLeft = true;
break;
case "ArrowRight":
case "KeyD":
playerState.moveRight = true;
break;
case "Space":
if (playerState.canJump) {
playerState.velocity.y = 10;
playerState.canJump = false;
}
break;
// 选择方块类型
case "Digit1":
blockSystem.selectedBlockType = BlockType.GRASS;
blockSystem.updateBlockSelection(BlockType.GRASS);
break;
case "Digit2":
blockSystem.selectedBlockType = BlockType.DIRT;
blockSystem.updateBlockSelection(BlockType.DIRT);
break;
case "Digit3":
blockSystem.selectedBlockType = BlockType.STONE;
blockSystem.updateBlockSelection(BlockType.STONE);
break;
}
}

// 键盘抬起事件
function handleKeyUp(event, playerState) {
switch (event.code) {
case "ArrowUp":
case "KeyW":
playerState.moveForward = false;
break;
case "ArrowDown":
case "KeyS":
playerState.moveBackward = false;
break;
case "ArrowLeft":
case "KeyA":
playerState.moveLeft = false;
break;
case "ArrowRight":
case "KeyD":
playerState.moveRight = false;
break;
}
}

main.js,主文件:

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
import * as THREE from "three";
import { initScene } from "./scene";
import { initPlayer, handleMovement } from "./player";
import { initBlockSystem } from "./blocks/blockSystem";
import { initEventListeners } from "./events";
// 初始化主要组件
const scene = initScene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;
const { controls, playerState } = initPlayer(camera, document.body);
const blockSystem = initBlockSystem(scene);

const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
if (controls.isLocked) {
const delta = clock.getDelta();
handleMovement(delta, playerState, controls, scene, camera);
}
renderer.render(scene, camera);
}

// 初始化和启动
function init() {
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
camera.position.set(0, 5, 0);
initEventListeners(controls, scene, camera, blockSystem, playerState);
animate();
}
init();