文章同步更新于我的个人博客:松果猿的博客,欢迎访问获取更多技术分享。
同时,您也可以关注我的微信公众号:松果猿的代码工坊,获取最新文章推送和编程技巧。
记录本人three.js的学习之路
前言
通过前两期关于three.js的文章,我们已经对three.js有了比较清晰的认识。下面跟着我制作一个three.js的简易版我的世界。
既然是简易版,下面是这个Minecraft必须要有的几个功能:
- 可以放置方块和破坏方块
- 前后左右移动跳跃
- 有简单的UI框,可以选择放置方块类型
当然我也是初学者,制作过程遇到了两个比较困难的点:
- 如何计算放置方块的位置
- 人物和方块的碰撞检测
这两个点我后面都会给出我的解决方法
演示: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 ) { 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) ); }
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();
|