# BSP挖洞

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title></title>
    <style>
        body { margin: 0; }
        canvas { width: 100%; height: 100%; }
    </style>
</head>
<body>
    <script src="js/three.js"></script>
    <script src="js/controls/OrbitControls.js"></script>
    <script src="js/threeBSP.js"></script>
    <script>
        var scene;
		var renderer;
		var camera;
		var w = window.innerWidth;
		var h = window.innerHeight;
		var h = 
        function initScene(){
            scene = new THREE.Scene();
        }
 
        
        function initCamera(){
            camera = new THREE.PerspectiveCamera( 60, w /h, 0.1, 1000);
            camera.position.z = 40;
            camera.position.y = 40;
            camera.position.x = 20;
            camera.lookAt({x:0,y:0,z:1});
            controls = new THREE.OrbitControls( camera );
        }
 
        
        function initRender(){
            renderer = new THREE.WebGLRenderer({antialias: true});
            renderer.setSize( w, h);
            document.body.appendChild( renderer.domElement );
            renderer.setClearColor(0xFFFFFF, 1.0);
        }
 
 
        function initObject(){
 
            // 墙面3
            var cubeGeometry = new THREE.BoxGeometry(1, 10, 30);
            var cube = new THREE.Mesh( cubeGeometry ); // 设置墙面位置
 
 
            // 窗户
            var door = new THREE.BoxGeometry(1, 8, 15);
            var doorMesh = new THREE.Mesh( door);
            doorMesh.position.z = 5
 
            var cubeBSP = new ThreeBSP(cube);
            var doorBSP = new ThreeBSP(doorMesh);
 
            resultBSP = cubeBSP.subtract(doorBSP); // 墙体挖窗户
            result = resultBSP.toMesh();
            
			
            var cubeGeometry = result.geometry
			
            var cubeMaterial = new THREE.MeshBasicMaterial({
            	map:THREE.ImageUtils.loadTexture('module/1.jpg')
            })
			
            qiangTiMesh = new THREE.Mesh(cubeGeometry,cubeMaterial);
            scene.add(qiangTiMesh);
        }
 
        function render() {
            requestAnimationFrame( render );
            renderer.render( scene, camera );
        }
        init();
        render();
 
        function init(){
            initRender();
            initScene();
            initCamera();
            initObject();
        }
    </script>
</body>
</html>

# 模型沿着轨迹进行运动

<!DOCTYPE html>
<html>
	<head>
		<title>Threejs加载城市obj模型,加载人物gltf模型,Tweenjs实现人物根据规划的路线运动</title>
		<script type="text/javascript" src="libs/three.js"></script>
		<script type="text/javascript" src="libs/OrbitControls.js"></script>
		<script type="text/javascript" charset="UTF-8" src="libs/Tween.min.js"></script>
		<script type="text/javascript" charset="UTF-8" src="libs/GLTFLoader.js"></script>

		<script type="text/javascript" src="libs/OBJLoader.js"></script>
		<script type="text/javascript" src="libs/MTLLoader.js"></script>
		<style>
			body {
				margin: 0;
				overflow: hidden;
			}
		</style>
	</head>
	<body>
		<div id="dom"></div>
		<script type="text/javascript">
			var camera;
			var renderer;
			var clock = new THREE.Clock();
			var mixer = new THREE.AnimationMixer();
			var clipAction
			var animationClip
			var pobj

			function init() {
				// 创建一个场景,它将包含我们所有的元素,如物体,相机和灯光。
				var scene = new THREE.Scene();

				var urls = [
					'assets/textures/posx.jpg',
					'assets/textures/negx.jpg',
					'assets/textures/posy.jpg',
					'assets/textures/negy.jpg',
					'assets/textures/posz.jpg',
					'assets/textures/negz.jpg'
				];

				var cubeLoader = new THREE.CubeTextureLoader();
				scene.background = cubeLoader.load(urls);

				// 创建一个摄像机,它定义了我们正在看的地方
				camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
				// 将摄像机对准场景的中心
				camera.position.x = 20;
				camera.position.y = 15;
				camera.position.z = 35;
				camera.lookAt(scene.position);
				var orbit = new THREE.OrbitControls(camera);

				// 创建一个渲染器并设置大小,WebGLRenderer将会使用电脑显卡来渲染场景
				// initialize basic renderer
				renderer = new THREE.WebGLRenderer({
					antialias: true,
					logarithmicDepthBuffer: true,
				});
				renderer.setSize(window.innerWidth, window.innerHeight);

				// 将平面添加到场景中
				var plane = createPlaneGeometryBasicMaterial();
				scene.add(plane);

				// 在屏幕上显示坐标轴
				var axes = new THREE.AxesHelper(100);
				scene.add(axes);

				// var trackballControls = initTrackballControls(camera, renderer);

				// 添加环境光
				scene.add(new THREE.AmbientLight(0x666666));
				scene.add(new THREE.AmbientLight("#ffffff", 1));
				// 将呈现器的输出添加到HTML元素
				document.getElementById("dom").appendChild(renderer.domElement);

				var points = initLine();
				// 将球体添加到场景中

				initModel();
				initPeople();

				// 启动动画
				renderScene();

				var i = 0;

				function tweenComplete() {
					if (i < points.length) {
						switch (i) {
							case 0:
								pobj.rotateY(Math.PI);
								break;
							case 1:
							case 5:
							case 8:
							case 9:
								pobj.rotateY(-0.5 * Math.PI);
								break;
							case 2:
							case 3:
							case 4:
							case 6:
							case 7:
								pobj.rotateY(0.5 * Math.PI);
								break;
							case 10:
								mixer.stopAllAction();
								break;

						}
						tween = new TWEEN.Tween(points[i])
							.to(points[i + 1], 3000)
							.easing(TWEEN.Easing.Linear.None)
							.onUpdate(function() {
								pobj.position.set(this.x, this.y, this.z);
							})
							.onComplete(tweenComplete)
							.start();
						i++;
					}
				}

				// 添加模型
				function initModel() {
					var mtlLoader = new THREE.MTLLoader();
					mtlLoader.setPath("assets/models/obj_mtl/")
					mtlLoader.load('city.mtl', function(materials) {
						materials.preload();

						var objLoader = new THREE.OBJLoader();
						objLoader.setMaterials(materials);
						objLoader.load('assets/models/obj_mtl/city.obj', function(object) {
							mesh = object;
							mesh.scale.set(3, 3, 3);
							mesh.position.y = -5;
							scene.add(mesh);
						});
					});
				}

				// 添加人物模型
				function initPeople() {
					var loader = new THREE.GLTFLoader();
					loader.load('assets/models/man/man.gltf', function(result) {
						result.scene.scale.set(1, 1, 1);
						result.scene.translateY(0);
						pobj = result.scene;
						scene.add(result.scene);

						tweenComplete();

						mixer = new THREE.AnimationMixer(result.scene);
						animationClip = result.animations[0];
						clipAction = mixer.clipAction(animationClip).play();
						animationClip = clipAction.getClip();
					});
				}

				// 创建一个平面
				function createPlaneGeometryBasicMaterial() {
					var textureLoader = new THREE.TextureLoader();
					var cubeMaterial = new THREE.MeshStandardMaterial({
						map: textureLoader.load("assets/textures/cd.jpg"),
					});
					cubeMaterial.map.wrapS = THREE.RepeatWrapping;
					cubeMaterial.map.wrapT = THREE.RepeatWrapping;
					cubeMaterial.map.repeat.set(18, 18)
					// 创建地平面并设置大小
					var planeGeometry = new THREE.PlaneGeometry(500, 500);
					var plane = new THREE.Mesh(planeGeometry, cubeMaterial);

					// 设置平面位置并旋转
					plane.rotation.x = -0.5 * Math.PI;
					plane.position.x = 0;
					plane.position.y = -5;
					plane.position.z = 0;
					return plane;
				}

				// 初始化线路
				function initLine() {
					var pArr = [{
						x: 5 * 3,
						y: -3.8,
						z: -0.7 * 3
					}, {
						x: -0.6 * 3,
						y: -3.8,
						z: -0.7 * 3
					}, {
						x: -0.6 * 3,
						y: -3.8,
						z: -1.8 * 3
					}, {
						x: -4 * 3,
						y: -3.8,
						z: -1.8 * 3
					}, {
						x: -4 * 3,
						y: -3.8,
						z: 2.8 * 3
					}, {
						x: -1.2 * 3,
						y: -3.8,
						z: 2.8 * 3
					}, {
						x: -1.2 * 3,
						y: -3.8,
						z: 4.3 * 3
					}, {
						x: 1.7 * 3,
						y: -3.8,
						z: 4.3 * 3
					}, {
						x: 1.7 * 3,
						y: -3.8,
						z: -0.4 * 3
					}, {
						x: 4.4 * 3,
						y: -3.8,
						z: -0.4 * 3
					}, {
						x: 4.4 * 3,
						y: -3.8,
						z: 5 * 3
					}];
					var points = [];
					var geometry = new THREE.Geometry();
					for (var i = 0; i < pArr.length; i++) {
						var randomX = pArr[i].x;
						var randomY = pArr[i].y;
						var randomZ = pArr[i].z;
						var vector = new THREE.Vector3(randomX, randomY, randomZ);
						geometry.vertices.push(vector);
						points.push(vector);
					}
					var material = new THREE.LineBasicMaterial({
						color: 0xff0000
					});
					var line = new THREE.Line(geometry, material);
					scene.add(line);
					return points;
				}

				// 动画渲染
				var step = 5;

				function renderScene() {
					TWEEN.update();
					orbit.update();
					var delta = clock.getDelta();
					mixer.update(delta);
					// 使用requestAnimationFrame函数进行渲染
					requestAnimationFrame(renderScene);
					renderer.render(scene, camera);
				}

				// 渲染的场景
				renderer.render(scene, camera);


				document.addEventListener('mousedown', onDocumentMouseDown, false);

				function onDocumentMouseDown(event) {
					// 点击屏幕创建一个向量
					var vector = new THREE.Vector3((event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window
						.innerHeight) * 2 + 1, 0.5);
					vector = vector.unproject(camera); // 将屏幕的坐标转换成三维场景中的坐标
					var raycaster = new THREE.Raycaster(camera.position, vector.sub(camera.position).normalize());
					var intersects = raycaster.intersectObjects(mesh.children, true);
					console.log(intersects)
					if (intersects.length > 0) {
						// intersects[0].object.material.color.set("#ffffff");
					}
				}

				// 创建一个球形几何体
				function createSphereGeometryLambertMaterial(point) {
					// 创建一个球体
					var sphereGeometry = new THREE.SphereGeometry(0.2, 20, 20);
					var sphereMaterial = new THREE.MeshBasicMaterial({
						color: 0x7777ff,
						wireframe: true
					});
					var sphereMaterial = new THREE.MeshLambertMaterial({
						color: 0xff0000
					});
					var sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
					// 设置该物体投射阴影
					sphere.castShadow = true;

					// 位置范围
					sphere.position.x = point.x;
					sphere.position.y = point.y;
					sphere.position.z = point.z;
					return sphere;
				}
			}
			window.onload = init;

			function onResize() {
				camera.aspect = window.innerWidth / window.innerHeight;
				camera.updateProjectionMatrix();
				renderer.setSize(window.innerWidth, window.innerHeight);
			}
			// 监听调整大小事件
			window.addEventListener('resize', onResize, false);
		</script>
	</body>
</html>

# 曲线轨迹动画

let curve = null;
function makeCurve() {
	curve = new THREE.CatmullRomCurve3([
		new THREE.Vector3(0, 0, 0),
		new THREE.Vector3(5, 0, 0),
		new THREE.Vector3(0, 0, 5)
	]);
	curve.curveType = "catmullrom";
	curve.closed = true;// 设置是否闭环
	curve.tension = 0.5; // 设置线的张力,0为无弧度折线

	// 为曲线添加材质在场景中显示出来,不显示也不会影响运动轨迹
	const points = curve.getPoints(50);
	const geometry = new THREE.BufferGeometry().setFromPoints(points);
	const material = new THREE.LineBasicMaterial({ color: 0x000000 });

	const curveObject = new THREE.Line(geometry, material);
	scene.add(curveObject)
}


let progress = 0; // 物体运动时在运动路径的初始位置,范围0~1
const velocity = 0.001; // 影响运动速率的一个值,范围0~1,和渲染频率结合计算才能得到真正的速率

// 物体沿线移动方法
function moveOnCurve() {
	if (curve == null || model == null) {
		console.log("Loading")
	} else {
		if (progress <= 1 - velocity) {
			const point = curve.getPointAt(progress); // 获取样条曲线指定点坐标
			const pointBox = curve.getPointAt(progress + velocity); // 获取样条曲线指定点坐标

			if (point && pointBox) {
				model.position.set(point.x, point.y, point.z);

				var targetPos = pointBox; //目标位置点
				var offsetAngle = 0; //目标移动时的朝向偏移

				// 以下代码在多段路径时可重复执行
				var mtx = new THREE.Matrix4()  // 创建一个4维矩阵
				// 构造一个旋转矩阵,从eye 指向 target,由向量 up 定向
				// lookAt(eye:Vector3, target:Vector3, up:Vector3 ):this
				mtx.lookAt(model.position, targetPos, model.up) // 设置朝向
				var euler = new THREE.Euler(0, offsetAngle, 0)
				mtx.multiply(new THREE.Matrix4().makeRotationFromEuler(euler))
				// 计算出需要进行旋转的四元数值
				var toRot = new THREE.Quaternion().setFromRotationMatrix(mtx)  
				model.quaternion.slerp(toRot, 0.2)
			}
			progress += velocity;
		} else {
			progress = 0;
		}
	}
};

// moveOnCurve()需要在渲染中一直调用更新,以达到物体移动效果
function animate() {
	requestAnimationFrame(animate);
	moveOnCurve();
	renderer.render(scene, camera);
};

# 山地高度可视化

// 1、山脉几何体y坐标范围
loader.load("../地形.glb", function (gltf) { 
    model.add(gltf.scene);
    const mesh = gltf.scene.children[0];
    const pos = mesh.geometry.attributes.position;
    const count = pos.count;

    // 1. 计算模型y坐标高度差
    const yArr = [];//顶点所有y坐标,也就是地形高度
    for (let i = 0; i < count; i++) {
        yArr.push(pos.getY(i));//获取顶点y坐标,也就是地形高度
    }
    yArr.sort();//数组元素排序,从小到大
    const miny = yArr[0];//y最小值
    const maxy = yArr[yArr.length - 1];//y最大值
    const height = maxy - miny; //山脉整体高度 
})

// 2、计算每个顶点的颜色值
const colorsArr = [];
const c1 = new THREE.Color(0x0000ff);//山谷颜色
const c2 = new THREE.Color(0xff0000);//山顶颜色
for (let i = 0; i < count; i++) {
    //当前高度和整体高度比值
    const percent = (pos.getY(i) - miny) / height;
    const c = c1.clone().lerp(c2, percent);//颜色插值计算
    colorsArr.push(c.r, c.g, c.b); 
}
const colors = new Float32Array(colorsArr);
// 设置几何体attributes属性的颜色color属性
mesh.geometry.attributes.color = new THREE.BufferAttribute(colors, 3);

// 3. 设置材质,使用顶点颜色渲染
mesh.material = new THREE.MeshLambertMaterial({
    vertexColors:true,
});

# Sprite模拟下雨

// 提供一个背景透明的png雨滴贴图,然后作为Sprite的颜色贴图
const texture = new THREE.TextureLoader().load("./雨滴.png");
const spriteMaterial = new THREE.SpriteMaterial({
    map: texture, 
});

// 雨滴在3D空间随机分布
const group = new THREE.Group();
for (let i = 0; i < 16000; i++) {
    // 精灵模型共享材质
    const sprite = new THREE.Sprite(spriteMaterial);
    group.add(sprite);
    sprite.scale.set(1, 1, 1);
    // 设置精灵模型位置,在长方体空间上上随机分布
    const x = 1000 * (Math.random() - 0.5);
    const y = 600 * Math.random();
    const z = 1000 * (Math.random() - 0.5);
    sprite.position.set(x, y, z)
}

// 周期性改变雨滴Sprite位置,根据时间计算Sprite位置
const clock = new THREE.Clock();
function loop() {
    // loop()两次执行时间间隔
    const t = clock.getDelta();
    group.children.forEach(sprite => {
        // 雨滴的y坐标每次减t*60
        sprite.position.y -= t*60;
        if (sprite.position.y < 0) {
            sprite.position.y = 600;
        }
    });
    requestAnimationFrame(loop);
}
loop();

// 相机镜头 near 参数调整大一些,避免相机镜头附近的雨滴偏大
// const camera = new THREE.PerspectiveCamera(30, width / height, 1, 3000);
const camera = new THREE.PerspectiveCamera(30, width / height, 50, 3000);

# 第一、三人称漫游

  • 记录键盘按键WASD状态
// 声明一个对象keyStates用来记录键盘事件状态
const keyStates = {
    // // false表示没有按下,true表示按下状态
    // keyW:false,
    // keyA:false,
    // keyS:false,
    // keyD:false,
};

// 当某个键盘按下设置对应属性设置为true
document.addEventListener('keydown', (event) => {
    keyStates[event.code] = true;
});
// 当某个键盘抬起设置对应属性设置为false
document.addEventListener('keyup', (event) => {
    keyStates[event.code] = false;
});


// 循环执行的函数中测试W键盘状态值
function render() {
    if(keyStates.W){
        console.log('W键按下');
    }else{
        console.log('W键松开');
    }
    requestAnimationFrame(render);
}
render();
  • W键控制人物模型运动
// 用三维向量表示玩家角色(人)运动漫游速度
const v = new THREE.Vector3(0, 0, 3);

// 渲染循环
const clock = new THREE.Clock();
function render() {
    const deltaTime = clock.getDelta();
    if (keyStates.W) {
        // 在间隔deltaTime时间内,玩家角色位移变化计算(速度*时间)
        const deltaPos = v.clone().multiplyScalar(deltaTime);
        player.position.add(deltaPos);// 更新玩家角色的位置
    }
    mixer.update(deltaTime);// 更新播放器相关的时间
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}
render();
  • 设置加速度、限制最高速度、设置阻尼减速
// 用三维向量表示玩家角色(人)运动漫游速度
const v = new THREE.Vector3(0, 0, 0);// 初始速度设置为0
const a = 12;// 加速度:调节按键加速快慢
const vMax = 5;// 限制玩家角色最大速度
const damping = -0.04;// 阻尼 当没有WASD加速的时候,角色慢慢减速停下来

// 渲染循环
const clock = new THREE.Clock();
function render() {
    const deltaTime = clock.getDelta();
    
	// 限制最高速度
	if (v.length() < vMax) {
		if (keyStates.W) {
			// 先假设W键对应运动方向为z
			const front = new THREE.Vector3(0,0,1);
			// W键按下时候,速度随着时间增加
			v.add(front.multiplyScalar(a * deltaTime));
		}
		// 后退
		if (keyStates.S) {
			// 与W按键相反方向
			const front = new THREE.Vector3(0, 0, -1);
			v.add(front.multiplyScalar(a * deltaTime));
		}
	}
	
	// 阻尼减速,乘以一个小于1的数值,这样重复多次执行以后,速度就会逼近0
	v.addScaledVector(v, damping);

	//更新玩家角色的位置  当v是0的时候,位置更新也不会变化
	const deltaPos = v.clone().multiplyScalar(deltaTime);
	player.position.add(deltaPos);
    
	mixer.update(deltaTime);
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}
render();
  • 相机跟着玩家走(第三人称漫游)
const camera = new THREE.PerspectiveCamera(30,...);
// 把相机作为玩家角色的子对象,这样相机的位置和姿态就会跟着玩家角色改变
player.add(camera);// 相机作为人的子对象

// 玩家角色后面一点  对应fov 30度
camera.position.set(0, 1.6, -5.5);
camera.lookAt(0, 1.6, 0);// 对着人身上某个点

// 注释相机空间OrbitControls代码,避免影响相机W、A、S、D对相机的控制
const controls = new OrbitControls(camera, renderer.domElement);
  • 鼠标左键拖动时候,旋转玩家角色
let leftButtonBool = false;// 记录鼠标左键状态
document.addEventListener('mousedown', () => {
    leftButtonBool = true;
});
document.addEventListener('mouseup', () => {
    leftButtonBool = false;
});


document.addEventListener('mousemove', (event) => {
    // 鼠标左键按下时候,才旋转玩家角色
    if(leftButtonBool){
		// event.movementX表示鼠标左右方向滑动的距离,单位是像素,往右滑动是正,往左滑动是负
		// 注意 rotation.y += 与 -= 区别,左右旋转时候方向相反
        player.rotation.y -= event.movementX / 600;
    } 
});
  • 鼠标上下移动只改变相机视角
// 这样虽然可以通过player控制子对象相机视角上下俯仰
// 但是玩家角色模型也必须跟着旋转,这样会改变人与地面位置关系
document.addEventListener('mousemove', (event) => {
    if(leftButtonBool){
        // 左右旋转
        player.rotation.y -= event.movementX / 600;
        // 玩家角色绕x轴旋转  视角上下俯仰
        player.rotation.x -= event.movementY / 600;
    } 
});


// 可以在相机camera和玩家角色模型player之间,嵌入一个子节点cameraGroup
// 作为相机的父对象,作为玩家角色模型player的子对象
// 层级关系:player <—— cameraGroup <—— camera
const cameraGroup = new THREE.Group();
cameraGroup.add(camera);
player.add(cameraGroup);

// 上下俯仰角度范围
const angleMin = THREE.MathUtils.degToRad(-15);// 角度转弧度
const angleMax = THREE.MathUtils.degToRad(15);
document.addEventListener('mousemove', (event) => {
    if(leftButtonBool){
        // 左右旋转
        player.rotation.y -= event.movementX / 600;
        // 鼠标上下滑动,让相机视线上下转动
        // 相机父对象cameraGroup绕着x轴旋转,camera跟着转动
        cameraGroup.rotation.x -= event.movementY / 600;
        // 一旦判断.rotation.x小于-15,就设置为-15,大于15,就设置为15
        if (cameraGroup.rotation.x < angleMin) {
            cameraGroup.rotation.x = angleMin;
        }
        if (cameraGroup.rotation.x > angleMax) {
            cameraGroup.rotation.x = angleMax
        };
    } 
});


  • 获取玩家(相机)正前方方向
function render() {
    if (v.length() < vMax) {
        if (keyStates.W) {
            const front = new THREE.Vector3();
			// obj.getWorldDirection()表示的获取obj对象自身z轴正方向在世界坐标空间中的方向
			// 模型没有任何旋转情况,.getWorldDirection()获取的结果(0,0,1)
            player.getWorldDirection(front);// 获取玩家角色(相机)正前方
            v.add(front.multiplyScalar(a * deltaTime));
        }
        if (keyStates.S) {
            const front = new THREE.Vector3();
            player.getWorldDirection(front);
            // - a:与W按键反向相反
            v.add(front.multiplyScalar(- a * deltaTime));
        }
    }
}
  • 玩家角色左右运动(叉乘)
// 两个向量a、b叉乘有一个特点,叉乘结果是一个同时垂直于a和b的向量
// 这就是说只要知道玩家角色模型人正前方方向和高度方向向量,就可以计算出来人的左右方向

玩家角色左右运动

// 玩家角色的正前方
const front = new THREE.Vector3();
player.getWorldDirection(front);

// 玩家角色的高度方向(竖直方向)
const up = new THREE.Vector3(0, 1, 0);// y方向

// A和D按键对应的方向计算代码
if (keyStates.A) {// 向左运动
    const front = new THREE.Vector3();
    player.getWorldDirection(front);
    const up = new THREE.Vector3(0, 1, 0);// y方向

    const left = up.clone().cross(front);
    v.add(left.multiplyScalar(a * deltaTime));
}
if (keyStates.D) {// 向右运动
    const front = new THREE.Vector3();
    player.getWorldDirection(front);
    const up = new THREE.Vector3(0, 1, 0);//y方向
    // 叉乘获得垂直于向量up和front的向量 左右与叉乘顺序有关
	// 可以用右手螺旋定则判断,也可以代码测试结合3D场景观察验证
    const right = front.clone().cross(up);
    v.add(right.multiplyScalar(a * deltaTime));
}
  • 鼠标点击页面随便一个位置,就会进入指针锁定模式
// 鼠标滑动时候,受到浏览器窗口范围限制,不能无限制移动,这时可以执行requestPointerLock()锁定指针
// 此时鼠标箭头不见了,鼠标可以上下左右无限滑动,按下键盘左上角Esc按键,鼠标指针箭头恢复到原来状态

// 当鼠标左键按下后进入指针锁定模式(鼠标无限滑动)
addEventListener( 'mousedown', () => {
    document.body.requestPointerLock();//body页面指针锁定
});
  • 在指针锁定模式下,改变玩家人姿态角度
// 鼠标左右移动,人绕y轴旋转
addEventListener('mousemove', (event) => {
    // 进入指针模式后,才能根据鼠标位置控制人旋转
    if (document.pointerLockElement == document.body) {
        // 鼠标左右滑动,让人左右转向(绕y轴旋转),相机会父对象人绕左右转向
        // 加减法根据左右方向对应关系设置,缩放倍数根据,相应敏感度设置
        person.rotation.y -= event.movementX / 500;
    }
});

// 执行document.exitPointerLock();可以退出指针锁定,或者键盘键盘Esc退出指针锁定模式
  • 第一人称视角,简单点说,就是看不到玩家角色的模型,相当于把相机放在人的前面
// camera.position.set(0, 1.6, -2.3);// 第三人称
// camera.lookAt(0, 1.6, 0);
camera.position.set(0, 1.6, 1);// 第一人称
camera.lookAt(0, 1.6, 2);// 目标观察点注意在相机位置前面一点
  • 第一、第三人称,快捷键v切换
let viewBool = true;// true表示第三人称,false表示第一人称
document.addEventListener('keydown', (event) => {
    if (event.code === 'KeyV') {
        if (viewBool) {
            // 切换到第一人称
            camera.position.z = 1;// 相机在人前面一点 看不到人模型即可
        } else {
            // 切换到第三人称
            camera.position.z = -2.3;// 相机在人后面一点
        }
        viewBool = !viewBool;
    }
});
  • 骨骼动画与运动状态关联
const clipArr = gltf.animations;//所有骨骼动画
const actionObj = {};//包含所有动作action
for (let i = 0; i < clipArr.length; i++) {
    const clip = aniArr[i];//休息、步行、跑步等动画的clip数据
    const action = mixer.clipAction(clip);//clip生成action
    action.name = clip.name;//action命名name
    // 批量设置所有动画动作的权重
    if (action.name === 'Idle') {
        action.weight = 1.0;//这样默认播放Idle对应的休息动画
    } else {
        action.weight = 0.0;
    }
    action.play();
    // action动画动作名字作为actionObj的属性
    actionObj[action.name] = action;
}

// 动作切换函数
let currentAction = actionObj['Idle'];//记录当前播放的动作
// 切换不同动作
function changeAction(actionName) {
    currentAction.weight = 0.0;//原来动作权重为0,不播放
    const action = actionObj[actionName];//新的需要播放的动作
    action.weight = 1.0;//将要播放的动作权重为1
    currentAction = action;//替换记录的动作
}

// 根据玩家角色速度v设置休息和步行动作
function playerUpdate(deltaTime) {
    const vL = v.length();
    if (vL < 0.2) {//速度小于0.2切换到站着休息状态
        // 如果当前就是Idle状态,不需要再次执行changeAction
        if (currentAction.name != 'Idle') changeAction('Idle');
    } else {//步行状态
        if (currentAction.name != 'Walk') changeAction('Walk');
    }
}

# 全景工具

使用3D引擎先搭一个基本的3D场景

var scene, camera, renderer;

function initThree(){
	var w = document.body.clientWidth;
	var h = document.body.clientHeight;
    //场景
    scene = new THREE.Scene();
    //镜头
    camera = new THREE.PerspectiveCamera(90, w / h, 0.1, 100);
    camera.position.set(0, 0, 0.01);
    //渲染器
    renderer = new THREE.WebGLRenderer();
    renderer.setSize(w, h);
    document.getElementById("container").appendChild(renderer.domElement);
    //镜头控制器
    var controls = new THREE.OrbitControls(camera, renderer.domElement);
    
    //一会儿在这里添加3D物体

    loop();
}

//帧同步重绘
function loop() {
    requestAnimationFrame(loop);
    renderer.render(scene, camera);
}

window.onload = initThree;

现在我们能看到一个黑乎乎的世界,因为现在scene里什么都没有,接着我们要把三维物体放进去了,使用3D引擎的实现方式无非都是以下几种

# 使用立方体(box)实现

这种方式最容易理解,我们在一个房间里,看向天花板,地面,正面,左右两面,背面共计六面。我们把所有六个视角拍成照片就得到六张图

使用立方体(box)实现

现在我们直接使用立方体(box)搭出这样一个房间

var materials = [];
//根据左右上下前后的顺序构建六个面的材质集
var texture_left = new THREE.TextureLoader().load( './images/scene_left.jpeg' );
materials.push( new THREE.MeshBasicMaterial( { map: texture_left} ) );

var texture_right = new THREE.TextureLoader().load( './images/scene_right.jpeg' );
materials.push( new THREE.MeshBasicMaterial( { map: texture_right} ) );

var texture_top = new THREE.TextureLoader().load( './images/scene_top.jpeg' );
materials.push( new THREE.MeshBasicMaterial( { map: texture_top} ) );

var texture_bottom = new THREE.TextureLoader().load( './images/scene_bottom.jpeg' );
materials.push( new THREE.MeshBasicMaterial( { map: texture_bottom} ) );

var texture_front = new THREE.TextureLoader().load( './images/scene_front.jpeg' );
materials.push( new THREE.MeshBasicMaterial( { map: texture_front} ) );

var texture_back = new THREE.TextureLoader().load( './images/scene_back.jpeg' );
materials.push( new THREE.MeshBasicMaterial( { map: texture_back} ) );

var box = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ), materials );
scene.add(box);

现在我们把镜头camera(也就是人的视角),放到box内,并且让所有贴图向内翻转后,VR全景就实现了

box.geometry.scale( 1, 1, -1 );

threejs官方立方体全景示例 (opens new window)

# 使用球体(sphere)实现

我们将房间360度球形范围内所有的光捕捉到一个图片上,再将这张图片展开为矩形,就能得到这样一张全景图片

使用球体(sphere)实现

//节点数量越大,需要计算的三角形就越多,影响性能
var sphereGeometry=new THREE.SphereGeometry(/*半径*/1,/*垂直节点数量*/50,/*水平节点数量*/50);

var sphere = new THREE.Mesh(sphereGeometry);
//用线框模式大家可以看得清楚是个球体而不是圆形
sphere.material.wireframe  = true;
scene.add(sphere);

现在我们把这个全景图片贴到这个球体上

var texture = new THREE.TextureLoader().load('./images/scene.jpeg');
var sphereMaterial = new THREE.MeshBasicMaterial({map: texture});

var sphere = new THREE.Mesh(sphereGeometry,sphereMaterial);
// sphere.material.wireframe  = true;

把镜头camera(也就是人的视角),放到球体内,并且让所有贴图向内翻转后,VR全景就实现了

var sphereGeometry = new THREE.SphereGeometry(/*半径*/1, 50, 50);
sphereGeometry.scale(1, 1, -1);

threejs官方球体全景示例 (opens new window)

# 添加信息点

在VR全景中,我们需要放置一些信息点,用户点击之后做一些动作

//建立点的数组
var hotPoints=[
    {
        position:{
            x:0,
            y:0,
            z:-0.2
        },
        detail:{
            "title":"信息点1"
        }
    },
    {
        position:{
            x:-0.2,
            y:-0.05,
            z:0.2
        },
        detail:{
            "title":"信息点2"
        }
    }
];


var pointTexture = new THREE.TextureLoader().load('images/hot.png');
var material = new THREE.SpriteMaterial( { map: pointTexture} );

//遍历这个数组,并将信息点的指示图添加到3D场景中
for(var i=0;i<hotPoints.length;i++){
    var sprite = new THREE.Sprite( material );
    sprite.scale.set( 0.1, 0.1, 0.1 );
    sprite.position.set( hotPoints[i].position.x
	                   , hotPoints[i].position.y
					   , hotPoints[i].position.z );

   scene.add( sprite );
}

添加点击事件,首先将全部的sprite放到一个数组里

sprite.detail = hotPoints[i].detail;
poiObjects.push(sprite);

然后我们通过射线检测(raycast),就像是镜头中心向鼠标所点击的方向发射出一颗子弹,去检查这个子弹最终会打中哪些物体

document.querySelector("#container").addEventListener("click",function(event){
    event.preventDefault();

    var raycaster = new THREE.Raycaster();
    var mouse = new THREE.Vector2();

    mouse.x = ( event.clientX / document.body.clientWidth ) * 2 - 1;
    mouse.y = - ( event.clientY / document.body.clientHeight ) * 2 + 1;

    raycaster.setFromCamera( mouse, camera );

    var intersects = raycaster.intersectObjects( poiObjects );
    if(intersects.length>0){
        alert("点击了热点"+intersects[0].object.detail.title);
    }
});

合成后的全景图工具 (opens new window)

# 元宇宙交互

效果预览 (opens new window)

初始化项目

//首先我们使用 vite 创建 vanilla-ts 项目,并且安装 Three.js。
pnpm create vite three-demo-4 --template vanilla-ts
cd three-demo-4
pnpm i
pnpm install three
pnpm i --save-dev @types/three

//使用 pnpm run dev 启动项目,打开 http://localhost:5173/,可以看到 vite 初始化的页面
我们直接把 main.ts 和 style.css 里面原来的代码删掉,在里面写我们的代码

创建场景、相机和渲染器

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 50);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.shadowMap.enabled = true;
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
camera.position.set(0, 3, 25);

添加背景色及灯光

scene.background = new THREE.Color(0.2, 0.2, 0.2);

const ambientLight = new THREE.AmbientLight(0xffffff, 0.1);
scene.add(ambientLight);

const directionLight = new THREE.DirectionalLight(0xffffff, 0.2);
scene.add(directionLight);

directionLight.lookAt(new THREE.Vector3(0, 0, 0));

添加展馆

let mixer: AnimationMixer;
new GLTFLoader().load('../resources/models/zhanguan.glb', (gltf) => {
	scene.add(gltf.scene);
	mixer = new THREE.AnimationMixer(gltf.scene);
})

渲染场景

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
  if (mixer) {
    mixer.update(0.02);
  }
}

animate();

当浏览器窗口变化时,实时调整

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight
  camera.updateProjectionMatrix()
  renderer.setSize(window.innerWidth, window.innerHeight)
})

给这个展馆添加各个屏幕及视频

new GLTFLoader().load('../resources/models/zhanguan.glb', (gltf) => {

  scene.add(gltf.scene);

  gltf.scene.traverse((child) => {

    child.castShadow = true;
    child.receiveShadow = true;

    if (child.name === '2023') {
      const video = document.createElement('video');
      video.src = "./resources/yanhua.mp4";
      video.muted = true;
      video.autoplay = true;
      video.loop = true;
      video.play();
      const videoTexture = new THREE.VideoTexture(video);
      const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture });
      (child as THREE.Mesh).material = videoMaterial;
    }

    if (child.name === '大屏幕01' || child.name === '大屏幕02' || 
	                   child.name === '操作台屏幕' || child.name === '环形屏幕2') {
      const video = document.createElement('video');
      video.src = "./resources/video01.mp4";
      video.muted = true;
      video.autoplay = true;
      video.loop = true;
      video.play();
      const videoTexture = new THREE.VideoTexture(video);
      const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture });
      (child as THREE.Mesh).material = videoMaterial;
    }

    if (child.name === '环形屏幕') {
      const video = document.createElement('video');
      video.src = "./resources/video02.mp4";
      video.muted = true;
      video.autoplay = true;
      video.loop = true;
      video.play();
      const videoTexture = new THREE.VideoTexture(video);
      const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture });
      (child as THREE.Mesh).material = videoMaterial;
    }

    if (child.name === '柱子屏幕') {
      const video = document.createElement('video');
      video.src = "./resources/yanhua.mp4";
      video.muted = true;
      video.autoplay = true;
      video.loop = true;
      video.play();
      const videoTexture = new THREE.VideoTexture(video);
      const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture });
      (child as THREE.Mesh).material = videoMaterial;
    }
  })

  mixer = new THREE.AnimationMixer(gltf.scene);
})

然后把人物加到展馆里面,并且更新 animate 函数

// 添加人物
let playerMixer: AnimationMixer;
let playerMesh: THREE.Group
let actionWalk: AnimationAction
let actionIdle: AnimationAction
const lookTarget = new THREE.Vector3(0, 2, 0);
new GLTFLoader().load('../resources/models/player.glb', (gltf) => {
  playerMesh = gltf.scene;
  scene.add(gltf.scene);

  playerMesh.traverse((child) => {
    child.receiveShadow = true;
    child.castShadow = true;
  })

  playerMesh.position.set(0, 0, 11.5);
  playerMesh.rotateY(Math.PI);

  playerMesh.add(camera);
  camera.position.set(0, 2, -5);
  camera.lookAt(lookTarget);

  const pointLight = new THREE.PointLight(0xffffff, 1.5);
  playerMesh.add(pointLight);
  pointLight.position.set(0, 1.8, -1);

  playerMixer = new THREE.AnimationMixer(gltf.scene);

  // 人物行走时候的状态
  const clipWalk = THREE.AnimationUtils.subclip(gltf.animations[0], 'walk', 0, 30);
  actionWalk = playerMixer.clipAction(clipWalk);

  // 人物停止时候的状态
  const clipIdle = THREE.AnimationUtils.subclip(gltf.animations[0], 'idle', 31, 281);
  actionIdle = playerMixer.clipAction(clipIdle);
  actionIdle.play();
});

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
  
  if (mixer) {
    mixer.update(0.02);
  }
  
  if (playerMixer) {
    playerMixer.update(0.015);
  }
}

下面让鼠标控制转镜头,按键盘的 W 让人物可以在展馆里行走

let isWalk = false;
const playerHalfH = new THREE.Vector3(0, 0.8, 0);
window.addEventListener('keydown', (e) => {
  if (e.key === 'w') {
    const curPos = playerMesh.position.clone();
    playerMesh.translateZ(1);
    const frontPos = playerMesh.position.clone();
    playerMesh.translateZ(-1);

    const frontVector3 = frontPos.sub(curPos).normalize()

    const raycasterFront = 
	      new THREE.Raycaster(playerMesh.position.clone().add(playerHalfH), frontVector3);
		  
    const collisionResultsFrontObjs = raycasterFront.intersectObjects(scene.children);

    if (collisionResultsFrontObjs && collisionResultsFrontObjs[0] 
	                              && collisionResultsFrontObjs[0].distance > 1) {
      playerMesh.translateZ(0.1);
    }

    if (!isWalk) {
      crossPlay(actionIdle, actionWalk);
      isWalk = true;
    }
  }
})

window.addEventListener('keyup', (e) => {
  if (e.key === 'w') {
    crossPlay(actionWalk, actionIdle);
    isWalk = false;
  }
});

let preClientX: number;
window.addEventListener('mousemove', (e) => {
  if (preClientX && playerMesh) {
    playerMesh.rotateY(-(e.clientX - preClientX) * 0.01);
  }
  preClientX = e.clientX;
});

function crossPlay(curAction: AnimationAction, newAction: AnimationAction) {
  curAction.fadeOut(0.3);
  newAction.reset();
  newAction.setEffectiveWeight(1);
  newAction.play();
  newAction.fadeIn(0.3);
}

最后给展馆设置阴影

// 设置阴影
directionLight.castShadow = true;

directionLight.shadow.mapSize.width = 2048;
directionLight.shadow.mapSize.height = 2048;

const shadowDistance = 20;
directionLight.shadow.camera.near = 0.1;
directionLight.shadow.camera.far = 40;
directionLight.shadow.camera.left = -shadowDistance;
directionLight.shadow.camera.right = shadowDistance;
directionLight.shadow.camera.top = shadowDistance;
directionLight.shadow.camera.bottom = -shadowDistance;
directionLight.shadow.bias = -0.001;

仓库地址 (opens new window)

# 其他案例

汽车展厅文字 (opens new window) 汽车展厅视频 (opens new window) 库房、档案室 (opens new window)