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