前言
精彩的世界盃決賽期間,參與了胖達老師基於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。
<script type="module" src="/src/study.js"></script>
這個時候,我們通過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),一起交流學習。