Threejs实现键盘控制人物行走跳动/Capsule碰撞体(胶囊体)/碰撞检测(Octree八叉树)/游戏第三人称/镜头跟随人物移动

个人主页: 左本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 表示碰撞的深度。将这两个值相乘,可以得到需要进行调整的距离向量,将其作为参数传入 playerCollidertranslate 方法中,就可以将玩家位置进行调整,避免穿模现象的发生。

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>
  • 7
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

左本Web3D

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值