Three.js元宇宙实战特训营 | 大帅老猿threejs特训

Three.js元宇宙实战特训营 | 大帅老猿threejs特训

最终效果

废话不多说,先来看下最终效果

GIF 2022-12-29 8-59-54.gif
这个就是本次特训营的最终案例,可以操作人物在指定的空间里走动。

Three.js的基础概念

实现这个效果,主要借助的是浏览器中WebGL的应用,而Three.js就是一个基于webGL的封装的一个易于使用且轻量级的3D库,帮我们进行快速开发。
在进行开发之前,我们先要熟悉一些基本概念:

01.png

02.png

03.png

04.png

05.png

06.png

07.png

在了解了这些概念之后可以简单的做一个案例,简单体验一下——这样一个旋转的甜甜圈:
08.png

首先是创建场景三大件:

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));

在导入场馆的模型之前,先大概了解一下视频纹理的方法
简单理解就是,把视频当作模型的贴图,放到模型上
image.png

然后就是加载场馆模型

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();
  });
})

这一步后,效果就是这样,可以看到模型已经进入场景,视频也在正常播放:
09.png

人物模型的加载

接下来是导入人物的模型。
同时,为了方便后面操作,在导入人物模型之后,添加一个摄像机,并且设置在人物正后方,就是游戏里常说的第三人称越肩视角。

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);
});

这个时候效果如下:
10.png

阴影添加

这些时候需要添加一些阴影,让场景看上去没那么生硬。

11.png

12.png

13.png

14.png

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);
}

最后要引入一个概念,就是模型的动画剪辑工具:
15.png

人物在移动的时候,不可能是直愣愣的移动,需要在移动的时候,播放走路的动作,在停下的时候,播放待机的动作,这样才显得自然。three.js提供了这样的方法,让我们根据帧数长短,来选择什么情况下播放那一段动画。

GIF 2022-12-29 10-22-15.gif

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
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值