个人主页: 左本Web3D,更多案例预览请点击==》 在线案例
个人简介:专注Web3D使用ThreeJS实现3D效果技巧和学习案例
💕 💕积跬步以至千里,致敬每个爱学习的你。喜欢的话请三连,有问题请私信或者加微信
1,功能介绍
Threejs实现键盘控制人物行走和跳动、使用八叉树Octree和胶囊体Capsule进行碰撞检测、人物头顶显示名称标签、镜头跟随人物移动并且镜头围绕人物旋转,类似游戏中第三人称。如下效果图
2,功能实现
Octree是一种基于八叉树的数据结构,使用Octree进行碰撞检测可以提高性能,特别是在场景中存在大量物体的情况下。
Capsule(胶囊体)是一种3D几何体,它由两个球体和一个圆柱体组成。通常被用作游戏和虚拟现实应用中的角色或物体的碰撞体积。
创建一个名为worldOctree
的Octree对象,并将gltf.scene中的所有网格节点添加到Octree中。
Octree是一种空间划分数据结构,可用于更有效地进行3D碰撞检测。Octree通过将3D空间分解成八个相等的子空间,然后递归地将每个子空间再分解成八个子空间,以此类推。在这个过程中,Octree会将所有网格对象分配到合适的节点中。这样做的好处是,在进行碰撞检测时,可以避免对整个场景中的所有网格进行遍历,而只需遍历与当前视野和碰撞体相关的那些网格。这样,可以大大减少碰撞检测的计算量,提高性能。
const worldOctree = new Octree();
worldOctree.fromGraphNode(gltf.scene);
创建Capsule.js实例来表示一个胶囊体,然后将该实例添加到Octree中,以便进行空间分区和碰撞检测。例如:
const playerCollider = new Capsule(position1, position2, radius);
const result = worldOctree.capsuleIntersect(playerCollider);
其中,position1和position2是线段的两个端点的位置,radius是球的半径。创建Capsule.js实例后,可以使用该实例来创建一个包含几何体信息的playerCollider对象,并使用worldOctree.capsuleIntersect()方法将该对象添加到Octree中。
const result = worldOctree.capsuleIntersect(playerCollider);
它首先调用了 worldOctree
中的 capsuleIntersect
方法,将玩家的碰撞体 playerCollider
作为参数传入,得到碰撞检测的结果 result
。 如果 result
存在,说明发生了碰撞,需要将玩家位置进行调整
function playerCollisions() {
const result = worldOctree.capsuleIntersect(playerCollider);
playerOnFloor = false;
if (result) {
playerOnFloor = result.normal.y > 0;
if (!playerOnFloor) {
playerVelocity.addScaledVector(result.normal, -result.normal.dot(playerVelocity));
}
playerCollider.translate(result.normal.multiplyScalar(result.depth));
}
}
这里使用了 result.normal.multiplyScalar(result.depth)
的方法,其中 result.normal
表示碰撞的法向量,result.depth
表示碰撞的深度。将这两个值相乘,可以得到需要进行调整的距离向量,将其作为参数传入 playerCollider
的 translate
方法中,就可以将玩家位置进行调整,避免穿模现象的发生。
3,源代码
<script type="importmap">
{
"imports": {
"three": "./libs/jsm/three.module.js",
"three/addons/": "./libs/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import {
OrbitControls
} from './libs/jsm/OrbitControls.js';
import {
GLTFLoader
} from './libs/jsm/GLTFLoader.js';
import {
Octree
} from './libs/jsm/math/Octree.js';
import {
Capsule
} from './libs/jsm/math/Capsule.js';
import {
GUI
} from './libs/jsm/lil-gui.module.min.js';
import Stats from './libs/jsm/stats.module.js';
const clock = new THREE.Clock();
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x88ccee);
scene.fog = new THREE.Fog(0x88ccee, 0, 50);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.rotation.order = 'YXZ';
camera.position.set(0, 0, 0);
const fillLight1 = new THREE.HemisphereLight(0x4488bb, 0x002244, 2.5);
fillLight1.position.set(2, 1, 1);
// scene.add(fillLight1);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(-5, 25, -1);
directionalLight.castShadow = true;
directionalLight.shadow.camera.near = 0.01;
directionalLight.shadow.camera.far = 500;
directionalLight.shadow.camera.right = 30;
directionalLight.shadow.camera.left = -30;
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = -30;
directionalLight.shadow.mapSize.width = 1024;
directionalLight.shadow.mapSize.height = 1024;
directionalLight.shadow.radius = 4;
directionalLight.shadow.bias = -0.00006;
scene.add(directionalLight);
const container = document.getElementById('container');
const renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild(renderer.domElement);
var oControls = new OrbitControls(camera, container);
oControls.update();
const stats = new Stats();
stats.domElement.style.position = 'absolute';
stats.domElement.style.top = '0px';
container.appendChild(stats.domElement);
scene.add(new THREE.AmbientLight(0xe0ffff, 1))
// scene.add(new THREE.HemisphereLight("#ffffff", "#6b6b6b", 2));
const geometry = new THREE.CapsuleGeometry(0.35, 0.7, 4, 8);
const material = new THREE.MeshBasicMaterial({
color: 0x00ff00,
wireframe: true
});
const capsule = new THREE.Mesh(geometry, material);
scene.add(capsule);
const GRAVITY = 30;
const NUM_SPHERES = 100;
const SPHERE_RADIUS = 0.2;
const STEPS_PER_FRAME = 5;
const sphereGeometry = new THREE.IcosahedronGeometry(SPHERE_RADIUS, 5);
const sphereMaterial = new THREE.MeshLambertMaterial({
color: 0xbbbb44
});
let peopleObj, activeAction, peopleAnimations, tween, mixer, peopleBox;
let mixerArr = [];
const spheres = [];
let sphereIdx = 0;
const worldOctree = new Octree();
const playerCollider = new Capsule(new THREE.Vector3(0, 0.35, 0), new THREE.Vector3(0, 1, 0), 0.26);
const playerVelocity = new THREE.Vector3();
const playerDirection = new THREE.Vector3();
let playerOnFloor = false;
let mouseTime = 0;
const keyStates = {};
const vector1 = new THREE.Vector3();
const vector2 = new THREE.Vector3();
const vector3 = new THREE.Vector3();
document.addEventListener('keydown', (event) => {
keyStates[event.code] = true;
});
document.addEventListener('keyup', (event) => {
keyStates[event.code] = false;
});
window.addEventListener('resize', onWindowResize);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function playerCollisions() {
const result = worldOctree.capsuleIntersect(playerCollider);
playerOnFloor = false;
if (result) {
playerOnFloor = result.normal.y > 0;
if (!playerOnFloor) {
playerVelocity.addScaledVector(result.normal, -result.normal.dot(playerVelocity));
}
playerCollider.translate(result.normal.multiplyScalar(result.depth));
}
}
function updatePlayer(deltaTime) {
let damping = Math.exp(-4 * deltaTime) - 1;
if (!playerOnFloor) {
playerVelocity.y -= GRAVITY * deltaTime;
// small air resistance
damping *= 0.1;
}
playerVelocity.addScaledVector(playerVelocity, damping);
const deltaPosition = playerVelocity.clone().multiplyScalar(deltaTime);
playerCollider.translate(deltaPosition);
playerCollisions();
let resPos = playerCollider.end;
capsule.position.copy(resPos);
if (peopleObj) {
peopleObj.position.copy(resPos.clone().setY(resPos.y - 0.7));
let pos = peopleObj.position.clone();
pos = pos.setY(pos.y + 0.7)
oControls.target = pos;
oControls.update();
} else {
oControls.object.position.set(0, 1, -2)
oControls.update(true); // 初始化
}
}
function getForwardVector() {
capsule.getWorldDirection(playerDirection);
playerDirection.y = 0;
playerDirection.normalize();
return playerDirection;
}
function getSideVector() {
capsule.getWorldDirection(playerDirection);
playerDirection.y = 0;
playerDirection.normalize();
playerDirection.cross(capsule.up);
return playerDirection;
}
function controls(deltaTime) {
// console.log(camera.rotation)
capsule.rotation.y = camera.rotation.y;
// gives a bit of air control
const speedDelta = deltaTime * (playerOnFloor ? 25 : 8);
if (keyStates['KeyW']) {
playerVelocity.add(getForwardVector().multiplyScalar(-speedDelta));
}
if (keyStates['KeyS']) {
playerVelocity.add(getForwardVector().multiplyScalar(speedDelta));
}
if (keyStates['KeyA']) {
playerVelocity.add(getSideVector().multiplyScalar(speedDelta));
}
if (keyStates['KeyD']) {
playerVelocity.add(getSideVector().multiplyScalar(-speedDelta));
}
if (playerOnFloor) {
if (keyStates['Space']) {
playerVelocity.y = 8;
}
}
}
// 创建文字精灵
var getTextCanvas = function(text) {
let option = {
fontFamily: 'Arial',
fontSize: 30,
fontWeight: 'bold',
color: '#ffffff',
actualFontSize: 0.08,
},
canvas, context, textWidth, texture, materialObj, spriteObj;
canvas = document.createElement('canvas');
context = canvas.getContext('2d');
// 先设置字体大小后获取文本宽度
context.font = option.fontWeight + ' ' + option.fontSize + 'px ' + option.fontFamily;
textWidth = context.measureText(text).width;
canvas.width = textWidth;
canvas.height = option.fontSize;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = option.color;
context.font = option.fontWeight + ' ' + option.fontSize + 'px ' + option.fontFamily;
context.fillText(text, textWidth / 2, option.fontSize / 1.8);
texture = new THREE.CanvasTexture(canvas);
materialObj = new THREE.SpriteMaterial({
map: texture
});
spriteObj = new THREE.Sprite(materialObj);
spriteObj.scale.set(textWidth / option.fontSize * option.actualFontSize, option.actualFontSize, option
.actualFontSize);
return spriteObj;
}
const loader = new GLTFLoader().setPath('assets/models/');
loader.load('collision-world.glb', (gltf) => {
let obj = gltf.scene;
obj.remove(obj.getObjectByName("polySurface150"));
scene.add(obj);
worldOctree.fromGraphNode(obj);
gltf.scene.traverse(child => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
if (child.material.map) {
child.material.map.anisotropy = 4;
}
}
});
animate();
});
let gloader = new GLTFLoader()
gloader.load("assets/models/man/people.glb", result => {
peopleObj = result.scene;
peopleObj.scale.set(0.7, 0.7, 0.7);
peopleAnimations = result.animations;
// 主人物名字
let spriteText = getTextCanvas("左本Web3D");
spriteText.position.set(0, 1.95, 0);
peopleObj.add(spriteText);
const bbox = new THREE.Box3().setFromObject(peopleObj);
// 获取包围盒的中心点
const center = new THREE.Vector3();
bbox.getCenter(center);
// 将物体移动到中心点
peopleObj.position.sub(center);
// 组合对象添加到场景中
scene.add(peopleObj);
mixer = new THREE.AnimationMixer(peopleObj);
mixerArr.push(mixer)
activeAction = mixer.clipAction(peopleAnimations[1]);
activeAction.play();
})
function teleportPlayerIfOob() {
if (camera.position.y <= -25) {
playerCollider.start.set(0, 0.35, 0);
playerCollider.end.set(0, 1, 0);
playerCollider.radius = 0.35;
camera.position.copy(playerCollider.end);
camera.rotation.set(0, 0, 0);
}
}
function animate() {
const deltaTime = Math.min(0.05, clock.getDelta()) / STEPS_PER_FRAME;
// we look for collisions in substeps to mitigate the risk of
// an object traversing another too quickly for detection.
for (let i = 0; i < mixerArr.length; i++) {
mixerArr[i].update(clock.getDelta());
}
for (let i = 0; i < STEPS_PER_FRAME; i++) {
controls(deltaTime);
updatePlayer(deltaTime);
// updateSpheres(deltaTime);
// teleportPlayerIfOob();
}
renderer.render(scene, camera);
stats.update();
requestAnimationFrame(animate);
}
</script>