Three.js元宇宙实战特训营 | 大帅老猿threejs特训
最终成果
废话不多说,先来看下最终成果
这个就是本次特训营的最终案例,能够操作人物在指定的空间里走动。
Three.js的根底概念
实现这个成果,次要借助的是浏览器中WebGL的利用,而Three.js就是一个基于webGL的封装的一个易于应用且轻量级的3D库,帮咱们进行疾速开发。
在进行开发之前,咱们先要相熟一些基本概念:
在理解了这些概念之后能够简略的做一个案例,简略体验一下——这样一个旋转的甜甜圈:
首先是创立场景三大件:
const scene = new THREE.Scene(); // 场景const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 10); // 相机const renderer = new THREE.WebGLRenderer({ antialias: true }); // 渲染器renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染器的宽高document.body.appendChild(renderer.domElement); // 放入dom元素中camera.position.set(0.3, 0.3, 0.5); // 调整相机的地位
为了能操作控制,还要创立控制器:
const controls = new OrbitControls(camera, renderer.domElement); // 创立控制器
创立环境光源:
const directionLight = new THREE.DirectionalLight(0xffffff, 0.4);scene.add(directionLight);
加载甜甜圈的模型:
new GLTFLoader().load('/models/donuts.glb', (gltf) => { scene.add(gltf.scene); // 将加载的模型放入场景 donuts = gltf.scene; mixer = new THREE.AnimationMixer(gltf.scene); const clips = gltf.animations; // 播放所有动画 clips.forEach(function (clip) { const action = mixer.clipAction(clip); action.loop = THREE.LoopOnce; action.clampWhenFinished = true; action.play(); });})
加载周围环境图片:
new RGBELoader() .load('/sky.hdr', function (texture) { scene.background = texture; texture.mapping = THREE.EquirectangularReflectionMapping; scene.environment = texture; renderer.outputEncoding = THREE.sRGBEncoding; renderer.render(scene, camera); });
最初是动画的办法:
function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); controls.update(); if (donuts){ donuts.rotation.y += 0.01; } if (mixer) { mixer.update(0.02); }}
残缺代码如下:
import * as THREE from 'three';import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader';function App() { let mixer; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 10); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); camera.position.set(0.3, 0.3, 0.5); const controls = new OrbitControls(camera, renderer.domElement); const directionLight = new THREE.DirectionalLight(0xffffff, 0.4); scene.add(directionLight); let donuts; new GLTFLoader().load('/models/donuts.glb', (gltf) => { scene.add(gltf.scene); donuts = gltf.scene; mixer = new THREE.AnimationMixer(gltf.scene); const clips = gltf.animations; // 播放所有动画 clips.forEach(function (clip) { const action = mixer.clipAction(clip); action.loop = THREE.LoopOnce; action.clampWhenFinished = true; action.play(); }); }) new RGBELoader() .load('/sky.hdr', function (texture) { scene.background = texture; texture.mapping = THREE.EquirectangularReflectionMapping; scene.environment = texture; renderer.outputEncoding = THREE.sRGBEncoding; renderer.render(scene, camera); }); function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); controls.update(); if (donuts){ donuts.rotation.y += 0.01; } if (mixer) { mixer.update(0.02); } } animate(); return ( <></> )}export default App
元宇宙场景的搭建
OK当初开始一步步实现文章最开始的那个元宇宙场景。
首先还是创立场景三大件
let mixer;let playerMixer;const scene = new THREE.Scene();const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 50);camera.position.set(5, 10, 25);const renderer = new THREE.WebGLRenderer({ antialias: true });renderer.shadowMap.enabled = true;renderer.setSize(window.innerWidth, window.innerHeight);document.body.appendChild(renderer.domElement);
创立光源
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));
在导入场馆的模型之前,先大略理解一下视频纹理的办法
简略了解就是,把视频当作模型的贴图,放到模型上
而后就是加载场馆模型
new GLTFLoader().load('/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 = "/yanhua.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } if (child.name === '大屏幕01' || child.name === '大屏幕02' || child.name === '操作台屏幕' || child.name === '环形屏幕2') { const video = document.createElement('video'); video.src = "/video01.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } if (child.name === '环形屏幕') { const video = document.createElement('video'); video.src = "/video02.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } if (child.name === '柱子屏幕') { const video = document.createElement('video'); video.src = "/yanhua.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } }) mixer = new THREE.AnimationMixer(gltf.scene); const clips = gltf.animations; // 播放所有动画 clips.forEach(function (clip) { const action = mixer.clipAction(clip); action.loop = THREE.LoopOnce; action.clampWhenFinished = true; action.play(); });})
这一步后,成果就是这样,能够看到模型曾经进入场景,视频也在失常播放:
人物模型的加载
接下来是导入人物的模型。
同时,为了不便前面操作,在导入人物模型之后,增加一个摄像机,并且设置在人物正后方,就是游戏里常说的第三人称越肩视角。
new GLTFLoader().load('/models/player.glb', (gltf) => { playerMesh = gltf.scene; scene.add(gltf.scene); 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);});
这个时候成果如下:
暗影增加
这些时候须要增加一些暗影,让场景看上去没那么僵硬。
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;
new GLTFLoader().load('/models/player.glb', (gltf) => { // ..... playerMesh.traverse((child)=>{ child.receiveShadow = true; child.castShadow = true; }) // .....
人物走动事件和对应动画
而后增加一些事件,让人物能够挪动和转向
const playerHalfHeight = 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(playerHalfHeight), 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; } } if (e.key === 's') { playerMesh.translateZ(-0.1); }})window.addEventListener('keyup', (e) => { if (e.key === 'w') { crossPlay(actionWalk, actionIdle); isWalk = false; }});let preClientX;window.addEventListener('mousemove', (e) => { if (preClientX && playerMesh) { playerMesh.rotateY(-(e.clientX - preClientX) * 0.01); } preClientX = e.clientX;});function crossPlay(curAction, newAction) { curAction.fadeOut(0.3); newAction.reset(); newAction.setEffectiveWeight(1); newAction.play(); newAction.fadeIn(0.3);}
最初要引入一个概念,就是模型的动画剪辑工具:
人物在挪动的时候,不可能是直愣愣的挪动,须要在挪动的时候,播放走路的动作,在停下的时候,播放待机的动作,这样才显得天然。three.js提供了这样的办法,让咱们依据帧数长短,来抉择什么状况下播放那一段动画。
new GLTFLoader().load('/models/player.glb', (gltf) => { // ... 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();});
结尾
到这里,所有的场景和人物操作事件就实现了,当然这就是一个简略的示例,如果要真正商业化的场景,须要思考很多事件,比方抉择视角和模型的关系等等。
上面是这个示例的残缺代码:
import * as THREE from 'three';import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader';function App() { let mixer; let playerMixer; 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(5, 10, 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)); 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; let playerMesh; let actionWalk, actionIdle; const lookTarget = new THREE.Vector3(0, 2, 0); new GLTFLoader().load('/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(); }); let isWalk = false; const playerHalfHeight = 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(playerHalfHeight), 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; } } if (e.key === 's') { playerMesh.translateZ(-0.1); } }) window.addEventListener('keyup', (e) => { if (e.key === 'w') { crossPlay(actionWalk, actionIdle); isWalk = false; } }); let preClientX; window.addEventListener('mousemove', (e) => { if (preClientX && playerMesh) { playerMesh.rotateY(-(e.clientX - preClientX) * 0.01); } preClientX = e.clientX; }); new GLTFLoader().load('/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 = "/yanhua.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } if (child.name === '大屏幕01' || child.name === '大屏幕02' || child.name === '操作台屏幕' || child.name === '环形屏幕2') { const video = document.createElement('video'); video.src = "/video01.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } if (child.name === '环形屏幕') { const video = document.createElement('video'); video.src = "/video02.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } if (child.name === '柱子屏幕') { const video = document.createElement('video'); video.src = "/yanhua.mp4"; video.muted = true; video.autoplay = "autoplay"; video.loop = true; video.play(); const videoTexture = new THREE.VideoTexture(video); const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture }); child.material = videoMaterial; } }) mixer = new THREE.AnimationMixer(gltf.scene); const clips = gltf.animations; // 播放所有动画 clips.forEach(function (clip) { const action = mixer.clipAction(clip); action.loop = THREE.LoopOnce; action.clampWhenFinished = true; action.play(); }); }) function crossPlay(curAction, newAction) { curAction.fadeOut(0.3); newAction.reset(); newAction.setEffectiveWeight(1); newAction.play(); newAction.fadeIn(0.3); } // const controls = new OrbitControls(camera, renderer.domElement); function animate() { requestAnimationFrame(animate); renderer.render(scene, camera); // controls.update(); if (mixer) { mixer.update(0.02); } if (playerMixer) { playerMixer.update(0.015); } } animate(); return ( <></> )}export default App