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