tips: 模型文件请求会有点慢的,见谅🫰
关于
对于threejs一直以来的印象就是很难、很难、还是很难!
所以一直没有时间来系统学习一下,都是断断续续看过一些文章,对于这个技术栈有一个大概的了解.
于是就想着写个Demo来边学边练.
初始化项目
新建一个vite项目:
npm create vite@latest
我选择的是vue3+ts项目,本人菜鸡,对react熟练度不是很够,ts也只是了解一点😭
项目初始化完毕之后就可以安装我们需要的安装包了:
npm i pinia
npm i three
定义模型信息
先初始化一个状态管理库用来存储模型数据
import { defineStore } from "pinia";
export const useModelsStore = defineStore('app', {
state: () => ({
}),
getters: {
},
actions: {
},
});
然后定义我们的数据
import { defineStore } from "pinia";
import { FBXLoader } from "three/examples/jsm/Addons.js";
import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
import Stats from 'three/addons/libs/stats.module.js';
import * as THREE from "three";
export const useModelsStore = defineStore('app', {
state: () => ({
FBXLoader: new FBXLoader(),
PLYLoader: new PLYLoader(),
renderer: new THREE.WebGLRenderer({ antialias: true }),
scene: new THREE.Scene(),
camera: new THREE.PerspectiveCamera(),
clock: new THREE.Clock(),
stats: new Stats(), // FPS计数器
light: new THREE.PointLight('#e2e1e4', 0),
group: new THREE.Group(),
mixer: {},
// 动作交互&方向
targetRotationY: 0, // 根据鼠标位置计算目标旋转角度
rotateSpeed: 0.1,
moveDirection_ws: new THREE.Vector3(), // 存储世界空间中的前进方向
moveDirection_ad: new THREE.Vector3(), // 存储世界空间中的前进方向
// 是否已加载场景
isLoad: false,
// 第三人称相机跟随配置
cameraConfig: {
distance: 50, // 相机距离角色的距离
height: 40, // 相机高度
smoothness: 0.1 // 平滑过渡系数,值越小越平滑
},
// 模型列表
models: [
{
progress: 0,
key: "Cheering",
url: "https://unpkg.com/e-cdn@1.0.0/micro-vue/Cheering.fbx",
position: [0, 1, 0],
scale: [.15, .15, .15],
model: null,
mixer: null,
actions: []
},
] as Model[],
// 动作列表
keys: [
{
key: 'q',
index: 0,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Crazy Gesture.fbx'
},
{
key: 'w',
index: 1,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/FastRun.fbx'
},
{
key: 'e',
index: 2,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Fighting Idle.fbx'
},
{
key: 'r',
index: 3,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Hip Hop Dancing.fbx'
},
{
key: 'a',
index: 4,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Standing Run Left.fbx'
},
{
key: 's',
index: 5,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Running Backward.fbx'
},
{
key: 'd',
index: 6,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Right Strafe.fbx'
},
{
key: 'f',
index: 7,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Whatever Gesture.fbx'
},
{
key: 'z',
index: 8,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Praying.fbx'
},
{
key: 'x',
index: 9,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Jab Cross.fbx'
},
{
key: 'c',
index: 10,
instancs: null,
progress: 0,
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Samba Dancing.fbx'
}
] as any[],
// 执行动作
checkKey: 0,
// 建筑模型
buildings: [
{
progress: 0,
key: 'Lucy100k',
url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Lucy100k.ply',
position: [50, 19, 50],
scale: [0.024, 0.024, 0.024],
}
] as Building[],
}),
getters: {
},
actions: {
},
});
初始化方法
初始化建筑物
// 初始化建筑物
initBuilding() {
this.buildings.forEach(async (building: Building) => {
building.model = await this.PLYLoader.loadAsync(building.url, (event: ProgressEvent) => {
building.progress =
Math.round((event.loaded / event.total) * 100 * 100) / 100;
});
building.model.scale(...building.scale);
building.model.computeVertexNormals();
const material = new THREE.MeshLambertMaterial();
const mesh = new THREE.Mesh(building.model, material);
mesh.position.set(...building.position);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
})
}
第三人称视角跟随函数 - 跟随物体
// 第三人称视角跟随函数 - 跟随物体
updateThirdPersonCamera(character: THREE.Group) {
// 计算相机在角色局部坐标系中的目标位置
// 这个位置在角色后方(distance)和上方(height)
const targetLocalPosition = new THREE.Vector3(
0,
this.cameraConfig.height,
-this.cameraConfig.distance
);
// 将局部坐标转换为世界坐标(考虑角色的旋转)
const targetWorldPosition = new THREE.Vector3()
.copy(targetLocalPosition)
.applyQuaternion(character.quaternion)
.add(character.position);
// 平滑过渡到目标位置
this.camera.position.lerp(targetWorldPosition, this.cameraConfig.smoothness);
// 让相机看向角色的前方一点(而不是中心点,更自然)
const lookAtPosition = new THREE.Vector3()
.set(0, 1, -2) // 在角色前方一点
.applyQuaternion(character.quaternion)
.add(character.position);
this.camera.lookAt(lookAtPosition);
}
初始化平行光
/**
* 初始化平行光
* @param position 光源位置
* @param target 照向位置
*/
initDirectionalLight(position: THREE.Vector3, color = '#ff0000', target: THREE.Vector3 = new THREE.Vector3(0, 0, 0)) {
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(position.x, position.y, position.z);
directionalLight.castShadow = true;
// 提高阴影贴图分辨率(值越大越清晰,但性能消耗越高)
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
// 调整阴影相机的可视范围(控制阴影覆盖区域)
directionalLight.shadow.camera.near = 5; // 近平面
directionalLight.shadow.camera.far = 500; // 远平面
directionalLight.shadow.camera.left = -100; // 左边界
directionalLight.shadow.camera.right = 100; // 右边界
directionalLight.shadow.camera.top = 100; // 上边界
directionalLight.shadow.camera.bottom = -100; // 下边界
directionalLight.target.position.set(target.x, target.y, target.z);
this.scene.add(directionalLight);
// 平行光辅助器
const helper = new THREE.DirectionalLightHelper(directionalLight, 5, color);
this.scene.add(helper);
}
初始化网格线辅助
// 初始化网格线辅助
initGridHelper() {
// 添加网格线辅助(可选,用于调试或网格效果)
const gridHelper = new THREE.GridHelper(800, 80, '#000000', '#000000');
gridHelper.position.y = 0.01; // 稍微高于地板避免Z轴冲突
this.scene.add(gridHelper);
}
初始化地板
// 初始化地板
initPlaneGeometry() {
// 地板 - 可以反光的地板
const PlaneGeometry = new THREE.PlaneGeometry(800, 800)
const MeshLambertMaterial = new THREE.MeshLambertMaterial({ color: '#f2f5f9' })
const plan = new THREE.Mesh(PlaneGeometry, MeshLambertMaterial)
plan.rotation.x = -0.5 * Math.PI
plan.receiveShadow = true
this.scene.add(plan)
}
初始化灯光
// 初始化灯光
initLight() {
this.light.intensity = 6999
this.light.position.set(0, 60, 0);
this.light.visible = true
this.light.castShadow = true;
this.group.add(this.light);
this.group.add(new THREE.PointLightHelper(this.light)) // 光源辅助器
this.scene.add(this.group);
}
初始化坐标轴辅助器
// 初始化坐标轴辅助器
initAxesHelper() {
const axesHelper = new THREE.AxesHelper(100);
axesHelper.position.set(0, 0.2, 0)
this.scene.add(axesHelper);
}
初始化锥体几何体(方向指示器)
// 初始化锥体几何体(方向指示器)
initConeGeometry() {
// 方向指示器(前端的小三角)
const indicatorGeometry = new THREE.ConeGeometry(0.5, 2, 3);
const indicatorMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const indicator = new THREE.Mesh(indicatorGeometry, indicatorMaterial);
indicator.position.z = -3; // 放在组的前端(-Z方向为前方)
indicator.rotation.x = Math.PI / 2;
this.group.add(indicator);
}
初始化运动方向
// 初始化运动方向
initMotion() {
// 计算前后方向的辅助向量
const ws = new THREE.Vector3(0, 0, 1); // 局部Z轴正方向为前方
// 计算左右方向的辅助向量
const ad = new THREE.Vector3(1, 0, 0); // 局部x轴负方向为左方
// 将局部前进方向转换为世界空间方向
this.moveDirection_ws.copy(ws).applyQuaternion(this.group.quaternion);
this.moveDirection_ad.copy(ad).applyQuaternion(this.group.quaternion);
}
初始化模型
// 初始化模型
async initModels() {
await Promise.all(this.models.map(this.loadModels));
this.scene.add(this.group);
}
加载模型
// 加载模型
async loadModels(model: Model): Promise<THREE.Object3D> {
// 加载模型动作
await Promise.all(this.keys.map((k) => {
return new Promise(async (resolve) => {
k.instancs = await this.FBXLoader.loadAsync(k.url, (event: ProgressEvent) => {
k.progress =
Math.round((event.loaded / event.total) * 100 * 100) / 100;
})
resolve(null)
})
}))
// 加载主模型
return new Promise(async (resolve) => {
model.model = await this.FBXLoader.loadAsync(
model.url,
(event: ProgressEvent) => {
model.progress =
Math.round((event.loaded / event.total) * 100 * 100) / 100;
}
);
model.model.castShadow = true;
model.model.receiveShadow = true;
model.model.scale.set(...model.scale)
model.mixer = new THREE.AnimationMixer(model.model)
model.model.animations.forEach((item: THREE.AnimationClip, idx: number) => {
model.actions[idx] = (model.mixer as THREE.AnimationMixer).clipAction(item)
})
/**
* 测试动作
*/
this.keys.forEach(key => {
key.instancs.animations.forEach((item: THREE.AnimationClip) => {
// 使用removePositionTracks函数过滤掉动画中所有控制位置的轨道(更彻底的解决方案)
if (['w', 'a', 's', 'd'].includes(key.key)) {
item.tracks = item.tracks.filter(track => {
// 排除所有包含.position的动画轨道
return !track.name.includes('.position');
});
}
model.actions[key.index] = (model.mixer as THREE.AnimationMixer).clipAction(item)
})
})
// 初始化动作
model.actions[0]?.play()
// 加入Group
this.group.add(model.model);
resolve(model.model);
});
}
渲染模型
// 渲染模型
async renderModels() {
this.models.forEach(mod => {
mod.mixer?.update(this.clock.getDelta())
})
// 根据按键状态更新位置和旋转
if ([1, 4, 5, 6].includes(this.checkKey)) {
this.initMotion()
// 按下w奔跑
if (this.checkKey == 1) { // w
this.group.position.add(this.moveDirection_ws.multiplyScalar(1));
}
if (this.checkKey == 4) { // a
this.group.position.add(this.moveDirection_ad.multiplyScalar(1));
}
if (this.checkKey == 5) {// s
this.group.position.add(this.moveDirection_ws.multiplyScalar(-1));
}
if (this.checkKey == 6) { // d
this.group.position.add(this.moveDirection_ad.multiplyScalar(-1));
}
}
// 相机跟随
this.group && this.updateThirdPersonCamera(this.group)
// 平滑过渡到目标角度(使用 lerp 实现平滑插值)
this.group.rotation.y = THREE.MathUtils.lerp(this.group.rotation.y, -this.targetRotationY, this.rotateSpeed);
this.renderer?.render(
toRaw(this.scene),
toRaw(this.camera)
);
this.stats.update()
}
设置动作响应
async setAnimations(index: number, idx: number) {
// console.log('setAnimations:', index, idx)
this.checkKey = idx
this.models[index]?.actions.forEach((actions, i) => {
if (idx !== i) {
actions.stop()
} else {
actions.play()
}
})
}
初始化Threejs
async init(Doms: HTMLElement) {
Doms.append(this.renderer.domElement);
Doms.append(this.stats.dom);
this.stats.dom.style.position = 'fixed'
this.stats.dom.style.top = '0'
this.stats.dom.style.bottom = '0'
// 初始化建筑物
this.initBuilding()
// 初始化场景元素
this.initAxesHelper()
this.initConeGeometry()
this.initLight()
this.initPlaneGeometry()
this.initGridHelper()
// this.initDirectionalLight(new THREE.Vector3(100, 100, 100));
// this.initDirectionalLight(new THREE.Vector3(-100, 100, -100));
// this.initDirectionalLight(new THREE.Vector3(100, 100, -100));
// this.initDirectionalLight(new THREE.Vector3(-100, 100, 100));
if (!this.isLoad) {
this.isLoad = true;
const { offsetWidth, offsetHeight } = Doms;
this.renderer.setClearColor('#000000')
this.renderer.setSize(offsetWidth, offsetHeight)
this.renderer.setAnimationLoop(this.renderModels)
this.renderer.shadowMap.enabled = true
this.camera = new THREE.PerspectiveCamera(75, offsetWidth / offsetHeight, 0.1, 1000)
this.camera.position.set(30, 30, 30); //设置相机位置
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(offsetWidth, offsetHeight);
// 交互
const controls = new OrbitControls(this.camera, this.renderer.domElement);
controls.update();
this.initModels();
}
}
开始使用
ok!一切万事俱备,我们来新建一个页面 模版部分
<div class="h-full w-full">
<div ref="refsThree" class="h-full"></div>
</div>
script部分
import { computed, onMounted, ref } from "vue";
import { useModelsStore } from '@/store/modules/models'
const modelsStore = useModelsStore()
const refsThree = ref()
onMounted(() => {
modelsStore.init(refsThree.value)
})
const keycode = ref<null | number>(null)
// 监听按键响应事件,执行对应动作
window.addEventListener('keydown', (e: KeyboardEvent) => {
const current = modelsStore.keys.find((v: any) => v.key === e.key)
if (current && keycode.value !== e.keyCode) {
keycode.value = e.keyCode
modelsStore.setAnimations(0, current.index)
}
})
// 移除当前动作 恢复默认动作
window.addEventListener('keyup', () => {
keycode.value = null
modelsStore.setAnimations(0, 0)
})
// 鼠标锁定状态
let isPointerLocked = false;
// 启动鼠标锁定(需用户交互触发)
window.addEventListener('click', () => {
document.documentElement.requestPointerLock();
isPointerLocked = true;
});
// 监听鼠标移动事件,更新鼠标位置
let mouseX = 0;
document.addEventListener('mousemove', (event: MouseEvent) => {
if (!isPointerLocked) {
// 鼠标位置变量(归一化坐标,范围[-1,1])
// 将鼠标坐标归一化到[-1,1](原点在屏幕中心)
mouseX = (event.clientX / window.innerWidth) * 2 - 1;
// 根据鼠标位置计算目标旋转角度
// 限制旋转范围(可选,避免过度旋转)
modelsStore.targetRotationY = mouseX * Math.PI / 2; // 绕Y轴最大旋转90度
} else {
const movementX = event.movementX || event.mozMovementX || 0;
if (movementX > 0) {
modelsStore.targetRotationY += (Math.PI / 180); // 绕Y轴旋转
} else if ((movementX < 0)) {
modelsStore.targetRotationY -= (Math.PI / 180); // 绕Y轴旋转
}
}
})
结语
到这里就结束了,后续还会继续学习改造这个demo🎉🎉🎉
感觉还是有很多bug,大佬们看到可以提示我改正一下,万分感激🙏🙏🙏
Three.js:网页端 3D 创作的「魔法钥匙」
当你在网页上见过旋转的 3D 产品模型、沉浸式虚拟展厅,或是数据驱动的三维可视化图表时,背后很可能藏着 Three.js 的身影。作为基于 WebGL 的 JavaScript 3D 库,它就像一位「技术翻译官」,将复杂的底层图形编程逻辑封装成直观 API,让开发者无需深钻 WebGL 的点线面绘制原理,就能在浏览器中构建出逼真的三维世界。
Three.js 的核心魅力在于「降低门槛却不减能力」。它由西班牙开发者 Ricardo Cabello(Mr.doob)于 2010 年发起,经过十余年社区迭代,已成为网页 3D 领域的事实标准 —— 每月持续更新的版本不断扩充功能边界,从基础的几何体、材质、光源,到高级的物理模拟、骨骼动画、VR/AR 适配,几乎覆盖所有 3D 创作需求。一个经典的 Three.js 应用只需三大核心组件:作为「舞台」的场景(Scene)、决定观察视角的相机(Camera),以及将内容渲染到画布的渲染器(Renderer),几行代码就能实现一个旋转的 3D 立方体,这种简洁性让新手也能快速入门。
如今,Three.js 已渗透到多元领域:游戏开发者用它打造网页端沉浸式关卡,建筑师借助它呈现可交互的建筑模型,数据分析师通过它将枯燥数据转化为立体图表,艺术家则用它创作动态数字装置。更重要的是,它无需任何插件,能在所有支持 WebGL 的现代浏览器中流畅运行,完美适配桌面与移动设备。
核心资源地址
- 官方网站:threejs.org/
- 中文文档:threejs.org/docs/index.…
- 版本下载与源码:github.com/mrdoob/thre…(含所有历史版本及最新特性)