大家好,我是王大傻。我又来了,随着对ThreeJS的学习,这次给大家带来的是元宇宙的雏形,也就是怎样用threeJs打造一个元宇宙世界。在这个世界中我们可以切换第一第三人称视角,以及简单的行走跳跃。相信大家在看完文章后也有自己的一些感触。
前期准备
项目基础构建
- Vue3
- Vite
- Ts
- Three
- 相关类库
- gsap 动画库
设计原型
这次项目我已经放在了github上面,主要功能有:
- 创建一个场景
- 创建一个人物模型(胶囊)
- 人物模型可以自由移动
- 人物模型具有上下以及障碍物碰撞、重力等具体功能
- 上次的打烟花函数植入、人物可以通过按键触发
- 人物可以切换视角
准备工作
首先,我们先来初始化一个threeJS的基本项目。
如图所示,我们在src下面创建了 shader three 这些基本文件夹,然后我们在对应文件夹下对我们项目进行封装。使其变为可复用的工程。
这里,我们回想一下,3D项目的三要素是什么,是场景(scene)、相机(camera)、以及我们的物体。 在此,我们首先对我们的场景以及相机进行封装。
场景:
相机:
完成这些之后,我们在component里面创建一个基本的Vue组件作为我们的承载体
在这里,因为我们只有场景相机,我们当然还需要一个渲染器作为处理层,提到渲染器我们同样也需要设置一个轮循函数对我们场景进行不断渲染,所以我们也单独抽离了渲染器
轮循函数:
到此,我们的基本设置已经完成。至于我们的物体以及一些例如修改函数了、灯光函数了、全凭我们自己开发时候的需要进行设置,在此大傻就不一一叙述了。让我们直接进入项目的开发思考环节。
中期项目思考
怎样构建碰撞模型
简单的场景构建在这大傻就不说了,可以直接下载源码查看,我们来聊下碰撞模型这块。说到碰撞这块,相信了解three的大家都知道,我们物理世界有个cannon-es库,他就可以帮助我们构建一个物理世界,当然这样构建的物理世界需要和我们3D世界一一对应。那么这样一来,如果我们一般的简单场景那岂不是也要一个物体一个物体的创建而且是两次,极为不便捷。好在Three里面也同样提供了 OcTree的检测模型,也就是常说的八叉树检测。这个检测感觉很丝滑,还有一定的类似物理的效果,不像之前简单的利用射线进行碰撞检测顶到墙之后噼里啪啦卡顿。了解完我们需要的技术后,就进入我们的开发环节。
代码初始化
首先我们先引入我们的八叉树,以及初始化一个物理世界。
import { Octree } from "three/examples/jsm/math/Octree.js";
export const group = new THREE.Group();
group.add(plane);
group.add(planeWall);
const worldOctree = new Octree();
worldOctree.fromGraphNode(group);
这里我们另外创建了一个组,讲这些符合物理碰撞规则的物体通通放入到我们的初始化的物理世界中,最终通过将这个组添加到我们的场景中来进行渲染。
然后我们需要对其他一些变量或常量做一个初始化操作
// 设置重力
const gravity = -9.8;
// 玩家的速度
const playerVelocity = new THREE.Vector3(0, 0, 0);
// 方向向量
const playerDirection = new THREE.Vector3(0, 0, 0);
// 玩家是否在地面上
let playerOnFloor = false;
这里我们初始化了四个变量,分别管控我们物理世界的重力(牛)、玩家的速度(因为是三维空间,所以是一个三维向量)、方向向量。至此位置我们的准备工作已经完成,接下来就是运动以及碰撞函数的编写
重要函数思路
根据键盘事件更新人物运动(运动函数)
键盘运动想必大家都熟知,WASD(前左右后),我们根据用户按下这几个按钮来为我们人物模型改变他的三维空间位置,当然还有Space(空格),来改变我们的垂直状态位置。函数如下: // 根据键盘状态更新玩家的速度
export function controlPlayer(deltaTime: number) {
if (keyStates["KeyW"]) {
playerDirection.z = 5;
//获取胶囊的正前面方向
const capsuleFront = new THREE.Vector3(0, 0, 0);
capsule.getWorldDirection(capsuleFront);
// 计算玩家的速度
playerVelocity.add(capsuleFront.multiplyScalar(deltaTime));
}
if (keyStates["KeyS"]) {
playerDirection.z = 5;
//获取胶囊的正前面方向
const capsuleFront = new THREE.Vector3(0, 0, 0);
capsule.getWorldDirection(capsuleFront);
// 计算玩家的速度
playerVelocity.add(capsuleFront.multiplyScalar(-deltaTime));
}
if (keyStates["KeyA"]) {
playerDirection.x = 5;
//获取胶囊的正前面方向
const capsuleFront = new THREE.Vector3(0, 0, 0);
capsule.getWorldDirection(capsuleFront);
// 侧方的方向,正前面的方向和胶囊的正上方求叉积,求出侧方的方向
capsuleFront.cross(capsule.up);
// console.log(capsuleFront);
// 计算玩家的速度
playerVelocity.add(capsuleFront.multiplyScalar(-deltaTime));
}
if (keyStates["KeyD"]) {
playerDirection.x = 5;
//获取胶囊的正前面方向
const capsuleFront = new THREE.Vector3(0, 0, 0);
capsule.getWorldDirection(capsuleFront);
// 侧方的方向,正前面的方向和胶囊的正上方求叉积,求出侧方的方向
capsuleFront.cross(capsule.up);
// console.log(capsuleFront);
// 计算玩家的速度
playerVelocity.add(capsuleFront.multiplyScalar(deltaTime));
}
if (keyStates["Space"]) {
playerVelocity.y = 5;
}
}
首先是通过设定一个基础的三维向量,为了给getWorldDirection传值用的,这个函数是Three的内置函数,用来判断我们当前相机朝向,因为我们是人物,所以人物的视角其实也就是我们的相机视角。而multiplyScalar函数则是给前面调用它的变量一个乘积,做一个向量乘法,这个值就是我们的时间,这样一来我们的前后左右都可以通过以下几个步骤完成
- 判断当前朝向
- 判断用户按下的键值 确定运动方向
- 规定一个运动速度
- 根据时间来进行运算我们运动后的位置
- 保存这些内容供我们赋值函数调用
人物碰撞检测(碰撞函数)
碰撞函数就是判断我们的人与物体发生的碰撞计算,因此就是人为参数:
export function playerCollisions() {
// 人物碰撞检测
const result = worldOctree.capsuleIntersect(playerCollider);
// console.log(result);
playerOnFloor = false;
if (result) {
// 判断当前是否位于平面
playerOnFloor = result.normal.y > 0;
playerCollider.translate(result.normal.multiplyScalar(result.depth));
}
}
这个函数主要是通过八叉树自带的方法capsuleIntersect来检测,拿到检测结果后我们对结果进行处理,首先是对我们当前是否在平面上这一个状态进行处理,其次还需要对我们物体的高度进行一个碰撞处理。在这里给大家提供一个工具
// 八叉树的坐标轴
import { OctreeHelper } from "three/examples/jsm/helpers/OctreeHelper.js";
const _octreeHelper = new OctreeHelper(worldOctree, 0xff0);
scene.add(_octreeHelper)
这个工具可以帮助我们更为直观的看到八叉树的模型
最后就是我们的一些工具函数
直接上代码
// 更新我们当前位置
export function updatePlayer(deltaTime: number) {
let damping = -0.05;
if (playerOnFloor) {// 判断是否在平面上 (掉下去就不是在平面,跳起来也不算,此时需要和我们前面设置的重力常量进行运算)
playerVelocity.y = 0;
keyStates.isDown || playerVelocity.addScaledVector(playerVelocity, damping);
} else {
playerVelocity.y += gravity * deltaTime;
}
// console.log(playerVelocity);
// 计算玩家移动的距离
const playerMoveDistance = playerVelocity.clone().multiplyScalar(deltaTime);
playerCollider.translate(playerMoveDistance);
// 将胶囊的位置进行设置
playerCollider.getCenter(capsule.position);
// 进行碰撞检测
playerCollisions();
}
// 重置人物位置
export function resetPlayer() {
if (capsule.position.y < -20) {
playerCollider.start.set(0, 2.35, 0);
playerCollider.end.set(0, 3.35, 0);
playerCollider.radius = 0.35;
playerVelocity.set(0, 0, 0);
playerDirection.set(0, 0, 0);
}
}
到这相信我们,已经可以正常的运动了
结尾锦上添花
在此,我们将之前的烟花函数进行一个整合,我们先思考下,之前烟花的起始位置都是固定的(开始位置固定、结束位置随机),我们需要调整为开始以及结束位置由我们来定义,首先开始位置不必说,肯定是我们视线前方,结束位置呢就是一个固定的偏差,也就是我们视线方向的向上取值,在这大傻直接用刚刚那个multiplyScalar乘一个固定值作为放大后的坐标位置。代码已经更新到github上了,有需要的童鞋可以自取。
调用烟花函数
export let createFireworks = (scene: THREE.Scene) => {
let color = `hsl(${Math.floor(Math.random() * 360)},100%,5%)`;
// 颜色随机生成
// 获取相机朝向
const capsuleFront = capsule.position.clone();
const positionY = playerVelocity.clone().y;
let toPosition = capsule.getWorldDirection(capsuleFront);
capsuleFront.multiplyScalar(10);
// 从哪开始?
let from = new THREE.Vector3(
capsule.position.clone().x,
capsule.position.clone().y + 2,
capsule.position.clone().z
);
// 去哪?
let to = new THREE.Vector3(
toPosition.x,
Math.abs(positionY) + capsule.position.clone().y+3,
toPosition.z
);
let firework = new Fireworks(color, to, from);
firework.addGroup(group);
fireworks.push(firework);
};
如上所示,大傻将开始位置设置为了我们胶囊(人物)的位置,结束位置则是放大后的位置以及我们在Y轴运动时候的向量Y,这里加上后我们跳起来同时也可以发射我们的炮弹,长按Q则是连发哦! 最终效果来了: