前言
之前看到过很多用threejs制作的炫酷场馆,本人对3D方面也很感兴趣,所以一直想学习一下threejs的使用。最近刚好学习了threejs的相关课程,自己也是从0到1完成了一个3D场馆的制作。就用这篇文章记录一下自己第一次学习threejs的成果。
成果展示
先看一看学习成果吧!
创建场馆模型
想要实现上面的效果,需要先通过软件创建一个3D的场馆模型,再用threejs中的api将场馆渲染到页面上,并实现各种动效。
创建场馆主体
打开Blender软件后,默认会展示一个立方体,摄像机和灯光,可以点击A键全选,再点击delete键删除。点击shift+A,添加柱体模型。
点击左下角添加柱体按钮,调整顶点的数值,顶点即柱体的边的数量,数值越大,柱体越接近圆柱。
再通过缩放将圆柱调整到合适的大小。
接下来需要将圆柱挖空,选中圆柱,按tab键进入编辑模式,按3进入面选择模式,选中顶面,右键选择内插面。
通过鼠标调整内插面的大小,确定后再右键选择挤出面,向下挤出,直到贴近底面。
接下来是制作场馆的入口部分,调整视角至俯视图,选择线框模式,通过框选删除几条边作为入口。
删除后可以看到墙的侧面是空的,需要处理一下。
这里需要在编辑模式下按2切换到边选择模式,选中墙体的一条边,然后按E键可以向外拉出面。
再按1切换至点选择模式,先选择拉出面的顶点,再按shift选择另一侧墙壁的顶点,右键选择吸附至像素点、选中项->活动项,就可以将两个顶点合并到一起了。
重复操作其它几个顶点,就完成了场馆主体模型的搭建。
创建中间立柱模型
同创建场馆主体一样,添加一个6边的柱体,调整至合适的大小和位置,调整物体位置时可以先按G键,再按X、Y、Z键使物体只在一个轴上移动。
然后需要将柱体从中间部分凸出,在编辑模式下,选择右上方的线框模式,在按2切换至线选择模式,选中6条竖边,按ctrl+R键生成一个横向的切边,调整至柱体中间部分。
再按S键缩放,将边向外拉出,就达到了凸出的效果。
然后再添加一个立方体,调整大小和位置,进入编辑模式,点选择模式,选中立方体上方的4个顶点,按S键将顶面拉大一点。
接着在其顶部通过ctrl+R创建一个切面,再选中各边,挤出各个面。
点击左侧移动按钮,将挤出的面向上提。
切换俯视图视角,点选择模式,选中顶点,使用缩放将顶部的宽度缩小一倍。当选中y轴方向上的4个顶点时,可以依次输入 S X 0.5,即可达到该效果。
最后再通过缩放调整一下长度,就完成了。
制作2023字体模型
接下来需要在入口处添加一个2023字样的字体屏幕,后面会在屏幕上播放烟花视频。
切换到物体模式,选择添加文本,沿x轴移动到入口位置,按tab键输入2023,通过旋转让文字立起来。
此时可以看到模型的原点在左下角,需要将原点设置在模型的中心,右键选择设置原点,原点->几何中心即可。再将文字旋转至和y轴平行。还需要将文字增加一点厚度,可以通过右侧的修改器选项按钮,选择实体化,调整厚度。
最后再给模型加一个底座,调整好位置和大小即可。
制作屏幕模型
场馆左侧有一个方形屏幕,中间操作台的6个面上也都有一个屏幕,这些直接通过立方体调整大小和位置就可完成。入口正对面是一个曲面屏,这个刚好可以利用场馆内侧的墙壁来制作。
选择场馆主体进入编辑模式,面选择模式,选中x轴两边数量相等的内侧墙壁面,按shift+D复制,将复制出来的面向外拉一点,然后右键,分离,选中项,调整至合适位置即可。
设置墙体背景
由于场馆的地面和墙体是一个物体,现在要给场馆的地面和墙体设置不同的背景,所以需要先分离一下。选中场馆,进入编辑模式,面选择模式,选中地面,右键,分离,选中项。在软件右侧场景集合里可以给每个模型重新命名,方便后面在threejs中获取到指定的模型。
准备一张大理石纹理的图片和一张墙体的图片,选中地面,点击软件上方的shading按钮,点击新建,新增一个材质。
将准备好的大理石纹理图片拖入下图位置,点击颜色旁边的黄点,按住鼠标左键,将其连接到右边的基础色上,这样地面上就出现了大理石花纹了。
在编辑模式下点击上方UV Editing按钮,可以看到地面上只展示了图片上的一小块区域,我们需要让图片在地面上多平铺几次,让场馆看起来更大一些。
鼠标点击左边区域,按A键全选,点击缩放按钮,将图中的圆向外拉倒合适大小即可。
点击上方modeling按钮,材质预览,可查看渲染效果。
墙面的背景操作步骤相同,选用不同的图片即可。因为墙体的面比较多,可以先在物体模式下选中墙体模型,再进入编辑模式,按A键全选,这样就可以在UV模式下看到所有面了。
调整屏幕UV
UV的位置和投影方式不对会导致视频播放时达不到我们想要的效果。方形屏幕的UV相对好调整一些,基本上只需要旋转UV的方向就可以了。
2023UV调整
由于2023现在任然是文字类型,需要先在物体模式下选中2023模型,右键,转换到,网格。
选中2023,进入编辑模式,按A全选,点击上方UV Editing按钮,鼠标点击左侧UV编辑区域,按A全选,点击UV按钮,展开,块面投影,就可以看到2023正确显示在区域中了。
将UV选择模式切换至孤岛选择模式,调整一下2023的位置即可。
曲面屏UV调整
物体模式下选中曲面屏,进入编辑模式,点击上方UV Editing按钮,可以发现投影的方向是有问题的。
老师在课上没有讲过曲面屏的调整方式,这里自己摸索了一下,就用了感觉比较简单的一种方法。点击左侧UV区域,按A键全选,然后点击上方UV,展开,展开,就可以看到曲面屏的面正确的显示在方框区域了。
再通过变换将曲面屏铺满方框区域就可以了。
导出glb文件
全部完成后,点击左上方文件,导出,gltf 2.0,给文件命名,备用。
获取角色模型
在sketchfab上有很多已经做好的人物模型,可以直接下载使用。下载完成后需要在mixamo上给角色加上动作动画,这里我就使用胖达老师给我们准备好的角色。
Threejs
所有准备工作都完成后,就可以开始写代码了。
使用vite新建一个项目,在项目中安装three即可。npm install three
然后将准备好的模型、视频放入项目中,在srcx目录下新建一个js文件,并在index.html文件中引入。
创建场景、相机、渲染器
首先,我们需要创建threejs中最基础的3样东西,场景、相机、渲染器。
import * as THREE from 'three';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
// 创建场景
const scene = new THREE.Scene();
// 创建相机
const camera = new THREE.PerspectiveCamera(75,window.innerWidth/window.innerHeight,0.01,100);
// 创建渲染器
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(window.innerWidth,window.innerHeight);
document.body.appendChild(renderer.domElement);
// 设置背景
scene.background = new THREE.Color(0.2,0.2,0.2);
加载灯光
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff,0.8);
scene.add(ambientLight);
// 方向光
const directionLight = new THREE.DirectionalLight(0xffffff,0.2);
scene.add(directionLight);
加载场馆
使用GLTFLoader加载glb模型。
// 创建场馆
// 加载gltf/glb模型
new GLTFLoader().load("../resources/models/changguan.glb",(gltf)=>{
console.log(gltf);
scene.add(gltf.scene);
});
帧循环
// 帧循环
function animate(){
requestAnimationFrame(animate);
renderer.render(scene,camera);
}
animate();
做完这些,就已经可以在页面中看到我们制作的场馆模型了。
设置相机
运行项目后,在浏览器打开发现看到的画面和我们预期的不太一样。
这是因为相机的位置问题,相机默认在原点,可以通过position将相机向外面挪一挪。
// 设置相机位置
camera.position.set(15,5,0);
再通过OrbitControls使用户可以控制相机,就可以通过鼠标来控制相机,查看整个场馆。
// 用户控制相机
const controls = new OrbitControls(camera,renderer.domElement);
点亮屏幕
接下来需要让各个屏幕播放对应的视频。在控制台中,可以看到在scene.children中存放着场馆中的所有物体模型。
不过我们可以用一种更简单的方法拿到我们需要的模型,traverse函数。
gltf.scene.traverse(child=>{
console.log("name:",child.name);
})
给对应的屏幕设置视频投影。
if(child.name=='2023' || child.name=='造型01'){
const video = document.createElement('video');
video.src = "../resources/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=='屏幕1'||child.name=='操作屏'){
const video = document.createElement('video');
video.src = "../resources/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 = "../resources/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;
}
这里视频在播放时可能会有横向播放或者倒着播放的情况,这是因为该物体的UV方向问题,在blender中选中该模型,进入UV模式,旋转播放视频的面UV方向即可。
加载角色模型
和加载场馆模型类似。
// 创建人物模型
let playerMesh;
new GLTFLoader().load("../resources/models/player.glb",gltf=>{
// 这里需要将人物模型提取出来,待后面备用
playerMesh=gltf.scene;
scene.add(playerMesh);
// 人物位置
playerMesh.position.set(13,0.18,0);
// 人物方向
playerMesh.rotateY(-Math.PI/2);
})
这样,我们就能看到角色出现在场馆中了。
角色控制
接下来需要让角色动起来,用按键W使角色向前移动。我们可以通过addEventListener实现。
window.addEventListener('keydown',e=>{
// 前进
if(e.key==='w'){
playerMesh.translateZ(0.1);
}
})
这里不再让用户控制相机,让用户控制角色,使相机跟随角色移动,永远处在角色的后方偏上的位置。将设置相机的代码先注释。
然后让相机跟随角色,给角色增加点光源。
为防止画面过亮,可以降低环境光亮度。
完成这些代码后,当我们按下W键,相机就会随着角色一起前进了。
然后通过监听鼠标在浏览器窗口中水平方向的移动距离来控制角色转向。
// 鼠标转向
let prePos;
window.addEventListener('mousemove',e=>{
if(prePos){
playerMesh.rotateY((prePos - e.clientX)*0.01);
}
prePos = e.clientX;
})
通过THREE.Raycaster实现角色碰撞检测。
截取角色站立、行走动画
由于角色的站立和行走动画是在一起的,所以需要通过subclip函数剪切出我们需要的动画。
// 剪切人物动作
playerMixer = new THREE.AnimationMixer(gltf.scene);
// 前30帧为行走动画
const clipWalk = THREE.AnimationUtils.subclip(gltf.animations[0],'walk',0,30);
actionWalk = playerMixer.clipAction(clipWalk);
// actionWalk.play();
// 后面为站立动画
const clipIdle = THREE.AnimationUtils.subclip(gltf.animations[0],'idle',31,281);
actionIdle = playerMixer.clipAction(clipIdle);
// 默认站立
actionIdle.play();
站立效果:
行走效果:
为了使动画切换更自然,使用渐变切换动画工具函数。
// 给动作切换时加一个淡入淡出效果,避免角色抖动
function crossPlay(curAction, newAction) {
curAction.fadeOut(0.3);
newAction.reset();
newAction.setEffectiveWeight(1);
newAction.play();
newAction.fadeIn(0.3);
}
在按下W键时由站立到行走,松开W键时由行走到站立。
let isWalk = false;
const playerHalfHeight = new THREE.Vector3(0,0.4,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;
}
}
})
window.addEventListener('keyup',e=>{
if(e.key==='w'){
crossPlay(actionWalk, actionIdle);
isWalk = false;
}
})
增加阴影
最后给角色和场馆加上阴影,就大功告成了!
完整代码
import * as THREE from 'three';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
let playerMixer;
// 创建场景
const scene = new THREE.Scene();
// 创建相机
const camera = new THREE.PerspectiveCamera(75,window.innerWidth/window.innerHeight,0.01,100);
// 创建渲染器
const renderer = new THREE.WebGLRenderer({antialias:true});
// 打开renderer阴影
renderer.shadowMap.enabled = true;
renderer.setSize(window.innerWidth,window.innerHeight);
document.body.appendChild(renderer.domElement);
// 设置背景
scene.background = new THREE.Color(0.2,0.2,0.2);
// 设置相机位置
// camera.position.set(15,5,0);
// 用户控制相机
// const controls = new OrbitControls(camera,renderer.domElement);
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff,0.2);
scene.add(ambientLight);
// 方向光
const directionLight = new THREE.DirectionalLight(0xffffff,0.2);
// 打开灯光阴影
directionLight.castShadow = true;
scene.add(directionLight);
// 设置灯光阴影贴图大小
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;
directionLight.position.set(10,10,10);
directionLight.lookAt(new THREE.Vector3(0,0,0));
// 创建一个绿色盒子
// const boxGeometry = new THREE.BoxGeometry(1,1,1);
// const boxMaterial = new THREE.MeshBasicMaterial({color:0x00ff00});
// const boxMesh = new THREE.Mesh(boxGeometry,boxMaterial);
// scene.add(boxMesh);
// 创建场馆
// 加载gltf/glb模型
new GLTFLoader().load("../resources/models/changguan.glb",(gltf)=>{
// console.log(gltf);
scene.add(gltf.scene);
gltf.scene.traverse(child=>{
// 场馆投影
child.castShadow = true;
child.receiveShadow = true;
if(child.name=='2023' || child.name=='造型01'){
const video = document.createElement('video');
video.src = "../resources/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=='屏幕1'||child.name=='操作屏'){
const video = document.createElement('video');
video.src = "../resources/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 = "../resources/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;
}
})
});
// 创建人物模型
let playerMesh;
let actionIdle; // 人物站立动画
let actionWalk; // 人物走路动画
new GLTFLoader().load("../resources/models/player.glb",gltf=>{
gltf.scene.traverse(child=>{
child.receiveShadow = true;
child.castShadow = true;
})
// 这里需要将人物模型提取出来,待后面备用
playerMesh=gltf.scene;
scene.add(playerMesh);
// 人物位置
playerMesh.position.set(13,0.18,0);
// 人物方向
playerMesh.rotateY(-Math.PI/2);
// 相机跟着人物走
playerMesh.add(camera);
camera.position.set(0,2,-3);
// 让相机看着人物的原点
// camera.lookAt(playerMesh.position);
camera.lookAt(new THREE.Vector3(0,0,1));
// 增加人物的亮度,给人物背后加一个点光源
const pointLight = new THREE.PointLight(0xffffff,0.8);
scene.add(pointLight);
// 让点光源跟着人物移动
playerMesh.add(pointLight);
// 设置点光源位置
pointLight.position.set(0,1.5,-2);
// 剪切人物动作
playerMixer = new THREE.AnimationMixer(gltf.scene);
// 前30帧为行走动画
const clipWalk = THREE.AnimationUtils.subclip(gltf.animations[0],'walk',0,30);
actionWalk = playerMixer.clipAction(clipWalk);
// actionWalk.play();
// 后面为站立动画
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.4,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;
}
}
})
window.addEventListener('keyup',e=>{
if(e.key==='w'){
crossPlay(actionWalk, actionIdle);
isWalk = false;
}
})
// 鼠标转向
let prePos;
window.addEventListener('mousemove',e=>{
if(prePos){
playerMesh.rotateY((prePos - e.clientX)*0.01);
}
prePos = e.clientX;
})
window.addEventListener('resize',()=>{
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth,window.innerHeight);
},false);
// 给动作切换时加一个淡入淡出效果,避免角色抖动
function crossPlay(curAction, newAction) {
curAction.fadeOut(0.3);
newAction.reset();
newAction.setEffectiveWeight(1);
newAction.play();
newAction.fadeIn(0.3);
}
// 帧循环
function animate(){
requestAnimationFrame(animate);
renderer.render(scene,camera);
if(playerMixer){
playerMixer.update(0.015);
}
}
animate();
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。