乐趣区

关于前端:Threejs元宇宙实战特训营-大帅老猿threejs特训

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
退出移动版