前言
精彩的世界杯决赛期间,参与了胖达老师基于Three.js&Blender的元宇宙搭建入门实训,趁着年前还有点记忆,来做个笔记。本来想在这篇笔记里面完整记下整个流程,但是篇幅实在太长了,本文暂时以Blender探索为主。
基础环境搭建
Three.js提供的API是可以让我们基于原生JavaScript随便玩的,但是为了让我们能在VSCode环境下有更好的代码提示和热更新,我们可以把Vite和Typescript利用起来(而且Three.js的API命名都比较长,对于我这种中式英语都说不好的人来说,纯手写压力太大)。
package.json部分配置如下:
{
"name": "vite-dashuailaoyuan",
"version": "0.0.1",
"scripts": {
"start": "vite --host",
// ...
},
"devDependencies": {
"@types/three": "^0.134.0",
"autoprefixer": "^10.4.0",
"prettier": "^2.5.0",
"sass": "^1.43.5",
"typescript": "^4.3.2",
"vite": "^2.6.14"
},
"dependencies": {
"three": "^0.134.0"
}
}
我本地使用的node版本是v14.18.1,我们可以通过npm/pnpm/yarn任一方式安装依赖。
依赖安装成功后,我们可以在/src
目录下新建一个JS文件,比如study.js
,引入three.js
以便于我们随后可以随意输出。
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';
console.log('====================================');
console.log(THREE);
console.log('====================================');
// ...
最后,在根目录的index.html
文件中引入study.js
。
这个时候,我们通过npm run start
来启动项目,在浏览器中打开控制台,可以看到three.js
的API被打印了出来。
初遇展馆模型
元宇宙到底火没火,能不能火?我作为一个小小的web开发无法预知,但是基于元宇宙延伸的web3D交互营销却是在慢热起来,而这些交互必然少不了场景。那我们就借助Blender这个免费开源跨平台的App来手撸一个展馆模型,后续就可以在three.js
中加载使用。
因为Blender推荐使用方便的快捷键来操作,一手键盘一手鼠标一把梭,所以我们的操作过程中就尽可能地熟悉快捷键操作。
清场
打开Blender,新建【常规】项目会默认给我们创建一个立方体box,这个时候我们要清场删掉一切,快捷键A
全选视图元素,快捷键X
唤起删除。
当然,我们也可以点选右侧面板元素,通过快捷键X
进行删除。
创建展馆
1、添加柱体
我们先来创建出场馆主体,使用组件快捷键Shift+A
唤起【添加】面板-【网格】-【柱体】,此时面板左下角会有针对我们当前操作项的一个编辑面板,我们可以编辑柱体的半径、深度和顶点,特别需要注意的是【柱体】的顶点数,顶点越多会生成越多的面,会使得曲面越圆滑,但是面数越多加载起来就越耗性能,因此我们要针对需求来合理设置顶点数,比如这里我们可以设置为120
就够用了。
2、复制面,向内挤出
我们点选【柱体】并使用快捷键Tab
来进入【编辑模式】,此时通过快捷键1
/2
/3
对应切换到点/边/面的编辑模式。
- 点选【柱体】
- 快捷键
Tab
进入编辑模式,3
进入面编辑模式 - 点选顶部的面,快捷键
I
进入【内切面】模式,按住鼠标左键移动来控制内切面的大小(调整场馆墙体的厚度),鼠标点击其他区域或者Enter
完成退出模式 - 快捷键
E
进入【挤出】模式,鼠标点按坐标Z轴向下移动,直至到底部平面位置,调整好位置后退出【挤出】模式
3、展馆的大门
为了更逼真,我们需要把封闭的柱体拆出来一个大门来。流程很简单:选中面并删除,缝合顶点。
首先呢,我们需要了解操作面板右上角的线框/实体/材质渲染预览的视图着色方式,切换这三种视图方式可以让我们编辑时更直观选中或预览渲染效果。
- 切换到线框视图模式
- 快捷键
Tab
进入到面编辑模式,通过滑动鼠标滚轮调整我们的视角,框选要删除的面(可以通过按着Shift
加选面) - 快捷键
X
进入删除模式,删除面
但是此时我们会发现,删除面以后,两侧的连接处是镂空的,我们需要把面缝合起来。
- 编辑模式下,快捷键
1
进入到点编辑模式,选中边缘的4个顶点 - 右键-从顶点创建边/面
好啦,我们的场馆大体基本完工啦,纯毛坯房啊有木有?这里只是基础的入门笔记,对于Blender而言,掌握常用快捷键就够啦。剩下的,就靠我们的反复click,就可以一点点搭建出更完善的细节,当然这个过程还需要更多的时间和耐心,以及兴趣。
如果你愿意给多一点时间,你能创建出一个自己满意的场景,起码不会比我的差哦。
中文的支持
虽说Blender菜单工具栏的国际化中文支持做的很不错,但是我们要在场景中添加中文文本,还是要稍微费一丢丢功夫。
添加文本编辑
比葫芦画瓢,我们参照创建柱体的方法(组合键Shift
+A
)来添加一个【文本】,默认文本内容是“Text”,但是我们怎么编辑文本内容呢?
大家还记得快捷键Tab
可以快速进入编辑模式么?同样地,我们使用这个快捷键,此时进入的就是文本的编辑模式啦。我们输入123
还是abc
都可以,但是就是输入不了中文。不知道是因为中文字体包太大,还是因为有这个需求的用户比较少,反正Blender目前(v3.4.0)版本预置的文本字体是不支持中文的。
想使用中文怎么办?那我们就需要自己引入中文字体。
引入中文字体
选中【文本】节点时,在工作区右侧会有一个【物体数据属性】的菜单,点击进入后有【字体】选择。点击对应字体右侧的目录图标,会自动进入系统字体目录,选择自己中意的字体即可。
输入中文文本
选好满意的字体,高高兴兴地输入了"新年快乐",发现依然输入不进去,这可怎么办呢?
莫慌,我们有一个经典的土方法:复制粘贴。把想要展示的文本输入到其他编辑器甚至搜索框任意可以复制的地方,复制粘贴进去。
文本立体化
但是我的文本节点就是一个面,这还怎么玩?那接下来,就要把文本立体化处理。
再次快捷键Tab
退出编辑模式,快捷键G
移动文本节点到合适的为止,快捷键R
旋转节点到合适的角度。
Tips:
1、移动节点时,如果我们担心节点位置乱了,可以锁定轴向进行移动。譬如我们想让节点沿着X轴移动,依次按下快捷键
G
和X
,再拖动就可以。2、旋转节点的时候,会发现原点不在几何中心,右键-设置原点-【原点->几何中心】。
旋转的时候,也可以通过左侧的【旋转】菜单,通过轴向坐标拖拽旋转,旋转时如果有固定角度,可以配合左下角当前编辑面板输入角度值进行旋转。
- 方式一:通过右侧面板【修改器属性】-【添加修改器】-【实体化】添加属性面板,设置【厚度】参数即可。
- 方式二:通过右侧面部【物体数据属性】-【几何数据】,设置【挤出】参数即可。
OK,到这里,关于Blender建模的常规操作已经基本都包含啦,大家可以继续舞起来啦~
代码笔记
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';
import dat from 'dat.gui';
import { Vector3 } from 'three';
const gui = new dat.GUI();
const parameters = {
cameraY: 2,
cameraZ: -6
}
let mixer;
let playerMixer;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
camera.position.set(5, 10, 100);
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);
directionLight.castShadow = true;
scene.add(directionLight);
directionLight.shadow.mapSize.width = 2048;
directionLight.shadow.mapSize.height = 2048;
const shadowDistance = 20;
directionLight.shadow.camera.near = 0.5;
directionLight.shadow.camera.far = 50;
directionLight.shadow.camera.left = -shadowDistance;
directionLight.shadow.camera.right = shadowDistance;
directionLight.shadow.camera.top = shadowDistance;
directionLight.shadow.camera.bottom = -shadowDistance;
directionLight.shadow.bias = -0.0001;
directionLight.position.set (10, 10, 10);
directionLight.lookAt(new THREE.Vector3(0, 0, 0));
let playerMesh;
let pointLight;
let actionIdle, actionWalk;
new GLTFLoader().load('../resources/models/player.glb', (gltf) => {
console.log(gltf);
gltf.scene.traverse((child) => {
child.castShadow = true;
child.receiveShadow = true;
})
playerMesh = gltf.scene;
scene.add(playerMesh);
playerMesh.position.set(12, -1, 0);
playerMesh.rotateY(Math.PI);
playerMesh.add(camera);
camera.position.set(0, parameters.cameraY, parameters.cameraZ);
camera.lookAt(playerMesh.position);
pointLight = new THREE.PointLight(0xffffff, 0.6);
pointLight.position.set(0, 2, -1);
scene.add(pointLight);
playerMesh.add(pointLight);
playerMixer = new THREE.AnimationMixer(gltf.scene);
const clipIdle = new THREE.AnimationUtils.subclip(gltf.animations[0], 'idle', 31, 281);
actionIdle = playerMixer.clipAction(clipIdle);
const clipWalk= new THREE.AnimationUtils.subclip(gltf.animations[0], 'walk', 0, 30);
actionWalk = playerMixer.clipAction(clipWalk);
});
let isChangeToWalk = true;
const playerHalfHeight = new THREE.Vector3(0, 1, 0);
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
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);
console.log(collisionResultsFrontObjs);
if(collisionResultsFrontObjs && collisionResultsFrontObjs[0].distance > 1) {
playerMesh.translateZ(1);
}
if(collisionResultsFrontObjs && collisionResultsFrontObjs.length === 0) {
playerMesh.translateZ(1);
}
if(isChangeToWalk) {
crossPlay(actionIdle, actionWalk);
isChangeToWalk = false;
}
}
})
window.addEventListener('keyup', (e) => {
console.log('keyup', e);
if (e.key === 'ArrowUp') {
crossPlay(actionWalk, actionIdle);
isChangeToWalk = true;
}
});
let prePos;
window.addEventListener('mousemove', (e) => {
if(prePos) {
playerMesh.rotateY(-(e.clientX - prePos) * 0.01);
}
prePos = e.clientX;
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false)
new GLTFLoader().load('../resources/models/zhanguan.glb', (gltf) => {
scene.add(gltf.scene);
gltf.scene.traverse((child) => {
child.castShadow = true;
child.receiveShadow = true;
if (child.name === '大帅老猿') {
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 === '大屏幕01' || child.name === '大屏幕02' || child.name === '操作台屏幕' || child.name === '环形屏幕2') {
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;
}
if (child.name === '柱子屏幕') {
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;
}
})
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);
}
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
// controls.update();
if (mixer) {
mixer.update(0.02);
}
if (playerMixer) {
playerMixer.update(0.015);
}
}
animate();
写到最后
不管是three.js还是Blender,内容都太多太多了,展开讲三天三夜都讲不完,何况我只是一个寻求入门的web开发。也基于此,在这篇笔记里,我也就暂时忽略了大家可能会比我还熟悉的three.js部分,只留下我这并不完善的代码,着重记录了我第一次接触Blender时遇到的卡壳的地方。
Emm,写得不好请见谅,我要去继续探索了,争取以后能分享给大家更多入门探索笔记。当然,如果你也感兴趣,那就加入猿创营 (v:dashuailaoyuan),一起交流学习。