需求背景
你现在有多个glb格式的模型文件,其中一些模型包含了动画,你现在需要在h5上渲染出模型
在此之前,你可能需要一点Threejs的基础~
threejs:threejs.org/docs/index....
普通版html怎么写
废话不多说,直接上代码
xml
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>3D demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
body { margin: 0; }
canvas { display: block; }
</style>
</head>
<body>
<div class="ctls"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.137.5/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three/examples/js/controls/OrbitControls.js"></script>
<script>
const scene = new THREE.Scene();
//环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 3);
scene.add(ambientLight);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = -80;
camera.position.x = 0;
camera.position.y = 0;
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.minDistance = 0;
controls.maxDistance = 20;
// 限制上下旋转角度
controls.minPolarAngle = Math.PI / 6;
controls.maxPolarAngle = Math.PI / 2;
const loader = new THREE.GLTFLoader();
const mixers = [];
const clock = new THREE.Clock(); // 计算时间差
// 加载多个模型
function loadModel(url) {
loader.load(
url,
function (gltf) {
scene.add(gltf.scene);
},
function (xhr) {
// 监控加载进度
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
function (error) {
console.error('An error happened loading ' + url, error);
}
);
}
// 加载含动画多个模型
function loadAnimationModel(url) {
loader.load(
url,
function (gltf) {
scene.add(gltf.scene);
if (gltf.animations && gltf.animations.length) {
const mixer = new THREE.AnimationMixer(gltf.scene);
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
mixers.push(mixer);
}
},
function (xhr) {
// 监控加载进度
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
function (error) {
console.error('An error happened loading ' + url, error);
}
);
}
// 假设我们需要加载三个模型
const modelUrls = ['./QYN_Mesh_Build_01.glb', './QYN_Mesh_Build_02.glb', './QYN_Mesh_Build_03.glb','./QYN_Mesh_Build_04.glb'];
const animationModelUrls = ['./QYN_Animation_Bird.glb','./QYN_Animation_ZhuLian.glb'];
const totalModels = modelUrls.length;
modelUrls.forEach((url) => {
loadModel(url);
});
animationModelUrls.forEach((url) => {
loadAnimationModel(url);
})
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixers.forEach(function(mixer) {
mixer.update(delta);
});
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
分几部分细说一下
初始化
首先需要对Threejs的一些固定的api进行初始化
- scene:场景,渲染的容器
- camera:相机,可以理解为人的眼睛,用于观察的
- control:控制器,用于控制相机的
- renderer:渲染器,用于渲染模型的
- light:灯光,用于控制光照效果的
ini
const scene = new THREE.Scene();
//环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 3);
scene.add(ambientLight);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = -80;
camera.position.x = 0;
camera.position.y = 0;
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
// 限制远近
controls.minDistance = 0;
controls.maxDistance = 20;
// 限制上下旋转角度
controls.minPolarAngle = Math.PI / 6;
controls.maxPolarAngle = Math.PI / 2;
加载模型
我们拥有了一些模型资产,想要通过Threejs渲染出来,就需要利用Threejs提供的一些Loader对模型资产进行加载转变成Threejs可以使用的资产,加载完成后添加到场景scene中就可以渲染出来了
如果是包含动画的模型,还需要额外处理下,简单介绍下用于控制动画的三个重要的东西
- AnimationClip:动画剪辑,是一个可重用的关键帧轨道集,它代表动画,一般美术会直接设计绑定放在模型的animations数组中了
- AnimationMixer:动画混合器,是用于场景中特定对象的动画的播放器
- AnimationAction:用来调度存储在AnimationClips中的动画,可以理解为一个动作
这三者是什么关系呢?
AnimationAction = AnimationMixer.clipAction(AnimationClip)
AnimationAction.play()就代表了开启了这个动作的动画
javascript
// 加载模型
function loadModel(url) {
loader.load(
url,
function (gltf) {
scene.add(gltf.scene);
},
function (xhr) {
// 监控加载进度
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
function (error) {
console.error('An error happened loading ' + url, error);
}
);
}
// 加载含动画模型
function loadAnimationModel(url) {
loader.load(
url,
function (gltf) {
scene.add(gltf.scene);
if (gltf.animations && gltf.animations.length) {
const mixer = new THREE.AnimationMixer(gltf.scene);
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
mixers.push(mixer);
}
},
function (xhr) {
// 监控加载进度
console.log((xhr.loaded / xhr.total * 100) + '% loaded');
},
function (error) {
console.error('An error happened loading ' + url, error);
}
);
}
// 假设我们需要加载三个模型
const modelUrls = ['./QYN_Mesh_Build_01.glb', './QYN_Mesh_Build_02.glb', './QYN_Mesh_Build_03.glb','./QYN_Mesh_Build_04.glb'];
const animationModelUrls = ['./QYN_Animation_Bird.glb','./QYN_Animation_ZhuLian.glb'];
modelUrls.forEach((url) => {
loadModel(url);
});
animationModelUrls.forEach((url) => {
loadAnimationModel(url);
})
循环动画
利用requestAnimationFrame开启流畅的动画循环
前面我们讲到包含动画的模型,在load时需要进行额外处理,开启了AnimationAction的动画
但是在循环动画中还需要根据deltaTime去更新AnimationMixer才会真正的渲染动画
scss
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixers.forEach(function(mixer) {
mixer.update(delta);
});
controls.update();
renderer.render(scene, camera);
}
animate();
ok根据以上代码就可以简单以html文件形式实现模型的渲染和动画了
进阶版vue怎么写
在项目中要引进3D渲染怎么搞呢?
其实很简单,和html相比区别没有很大
相比html的版本,这里新增了几个方法
- reset:重置模型视角
- updateSize:监听resize,动态更新渲染区域大小
- zoomIn: 前进
- zoomOut:后退
- gotoTargetPoint:点击去到某个点
直接上代码
ini
<template>
<div ref="modelContainer" class="model-container" />
</template>
<script lang="ts" setup name="ModelRenderer">
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { ref, onMounted, onUnmounted } from 'vue';
const modelContainer = ref<HTMLDivElement>();
let requestId = 0;
let camera: THREE.PerspectiveCamera;
let scene: THREE.Scene;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let loader: GLTFLoader;
const clock: THREE.Clock = new THREE.Clock();
const mixers: THREE.AnimationMixer[] = [];
const emit = defineEmits(['model-loaded']);
const init = () => {
if (!modelContainer.value) return;
const width = modelContainer.value.offsetWidth;
const height = modelContainer.value.offsetHeight;
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
modelContainer.value.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = -80;
camera.position.x = 0;
camera.position.y = 0;
controls = new OrbitControls(camera, renderer.domElement);
controls.minDistance = 0;
controls.maxDistance = 20;
// 限制上下旋转角度
controls.minPolarAngle = Math.PI / 6;
controls.maxPolarAngle = Math.PI / 2;
};
// 加载多个模型
const loadModel = (url: string) => {
loader.load(
url,
function (gltf) {
scene.add(gltf.scene);
},
() => {},
function (error) {
console.error('An error happened loading ' + url, error);
}
);
};
// 加载含动画多个模型
const loadAnimationModel = (url: string) => {
loader.load(
url,
function (gltf) {
scene.add(gltf.scene);
if (gltf.animations && gltf.animations.length) {
const mixer = new THREE.AnimationMixer(gltf.scene);
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
mixers.push(mixer);
}
},
() => {},
function (error) {
console.error('An error happened loading ' + url, error);
}
);
};
const render = (options?: string[]) => {
console.log('🚀 ~ render ~ options:', options);
try {
const modelUrls = [
'src/assets/models/QYN_Mesh_Build_01.glb',
'src/assets/models/QYN_Mesh_Build_02.glb',
'src/assets/models/QYN_Mesh_Build_03.glb',
'src/assets/models/QYN_Mesh_Build_04.glb',
];
const animationModelUrls = [
'src/assets/models/QYN_Animation_Bird.glb',
'src/assets/models/QYN_Animation_ZhuLian.glb',
];
const manager = new THREE.LoadingManager(
// Loaded
async () => {
emit('model-loaded');
},
// Progress
(itemUrl, itemsLoaded, itemsTotal) => {
const loadedData = Math.floor((itemsLoaded / itemsTotal) * 100);
if (loadedData >= 100) {
console.log('🚀 ~ render ~ loadedData:', loadedData);
emit('model-loaded');
}
},
// error
() => {
console.log('error');
}
);
loader = new GLTFLoader(manager);
modelUrls.forEach((url) => {
loadModel(url);
});
animationModelUrls.forEach((url) => {
loadAnimationModel(url);
});
} catch (e) {
console.error(e);
}
};
const animate = () => {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixers.forEach(function (mixer) {
mixer.update(delta);
});
controls.update();
renderer.render(scene, camera);
};
const resetControls = () => {
controls.reset();
};
const updateSize = () => {
if (!modelContainer.value) return;
const width = modelContainer.value.offsetWidth;
const height = modelContainer.value.offsetHeight;
renderer.setSize(width, height);
renderer.render(scene, camera);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
const zoomToggle = (isIn: boolean) => {
const direction = new THREE.Vector3()
.subVectors(scene.position, camera.position)
.normalize();
const minDistance = 1; // 最小距离为1米
let currentDistance = camera.position.distanceTo(scene.position);
if (isIn && currentDistance > minDistance) {
camera.position.add(direction.multiplyScalar(1));
} else if (!isIn) {
camera.position.sub(direction.multiplyScalar(1));
}
// 重新计算距离,以确保不会因为移动而超过最小距离
currentDistance = camera.position.distanceTo(scene.position);
if (currentDistance < minDistance) {
camera.position.add(
direction.multiplyScalar(currentDistance - minDistance)
);
}
camera.lookAt(scene.position);
if (controls) controls.update();
};
const zoomIn = () => zoomToggle(true);
const zoomOut = () => zoomToggle(false);
// 添加鼠标双击啊事件监听
const gotoTargetPoint = (event: MouseEvent) => {
var mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
var raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
var intersects = raycaster.intersectObjects(scene.children);
console.log('🚀 ~ intersects:', intersects);
if (intersects.length > 0) {
// 设置相机的位置到被点击的点
camera.position.set(
intersects[0].point.x,
intersects[0].point.y,
intersects[0].point.z
);
}
};
defineExpose({
render,
updateSize,
resetControls,
zoomIn,
zoomOut,
});
onMounted(() => {
init();
animate();
window.addEventListener('resize', updateSize, false);
window.addEventListener('dblclick', onMouseDbClick, false);
render();
});
onUnmounted(() => {
window.removeEventListener('resize', updateSize, false);
window.removeEventListener('dblclick', onMouseDbClick, false);
cancelAnimationFrame(requestId);
});
</script>
<style scoped>
.model-container {
width: 100vw;
height: 100vh;
}
</style>
高阶版动画系统怎么写
其实通过进阶版的写法,已经可以在Vue项目中游刃有余了,但是如果3D场景很多,且模型格式不同、渲染需求不同,那就要写很多像这样的Vue组件,但是像zoomIn、zoomOut、gotoTargetPoint或者是一些其他控制动画的方法是可以复用的
因此我们可以适当的抽离一些功能,按照开闭原则设计一个高复用性的集合3D渲染和动画的简易框架
参考UE中的GamePlay框架,我们可以这么设计,将整个3D场景划分为这么几个类
- World:顾名思义为一个世界,即虚拟数字人存在的世界。在 world 里可对场景进行设置,如灯光效果、天空效果、地板效果等。
- Actor:可以认为 Actor 为一个个体,可以是一个人或者一个物品,其可以拥有多个组件(Component)来表示其外观或操作;Actor 也可以拥有子 Actor。
- Component:Component是Actor 的组成部分,可以理解为人身上的挂件;组件拥有有位置属性;组件也可以拥有子组件。
- Camera:相机相当于人的眼睛,又分为正交投影和透视投影,不同的投影方式,模型的渲染效果也有差异。通过相机的位置可以 3D 预览模型,可近看也可以远观。其可以利用Controller 控制相机,进行缩放、旋转等操作。
设计类图大概如下:
这个设计是比较符合开闭原则的
- 可以利用多态增加不同的Actor继承于ActorBase,比如人Character、车Car、树Tree
- Actor上可以增加不同类型的component,控制不同的功能,比如用于控制mesh的MeshComponent、用于控制动画的DriveComponent。这也是符合单一责任原则的。
这个简易框架设计好后,现在的vue文件应该长什么样呢
除了一些和dom相关的,其他的代码逻辑被封装了,完全不需要理解了,直接开箱即用
ini
<template>
<div
ref="modelContainer"
class="model-container"
></div>
</template>
<script lang="ts" setup name="ModelRenderer">
import * as THREE from 'three';
import { WebGLRenderer } from 'three';
import Stats from 'three/examples/jsm/libs/stats.module.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { onMounted, onUnmounted, PropType, ref } from 'vue';
import ActorBase from '@/common/3D-render/core/actors/actor-base';
import GltfActor from '@/common/3D-render/core/actors/gltf-actor';
import type DriveComponent from '@/common/3D-render/core/components/drive-component';
import CameraActor from '@/common/3D-render/logic/camera-actor';
import World from '@/common/3D-render/logic/world';
import { IS_DEBUG } from '@/utils/index';
const modelContainer = ref<HTMLDivElement>();
let requestId = 0;
let camera: CameraActor;
let renderer: THREE.WebGLRenderer;
let loader: GLTFLoader;
let world: World;
let stats: Stats;
const clock: THREE.Clock = new THREE.Clock();
const emit = defineEmits<{
(e: 'modelLoaded', value: number): void;
(e: 'modelLoadError'): void;
(e: 'modelLoadTimeout'): void;
}>();
/**
* 初始化模型
* 初始化World:初始化scene、灯光、hdr场景等
* 初始化Render:初始化WebGL渲染器
* 初始化Camera:初始化相机,设置相机参数、远近、旋转角度等
* 初始化stats:初始化性能监测工具
*/
const init = () => {
if (!modelContainer.value) return;
const width = modelContainer.value.offsetWidth;
const height = modelContainer.value.offsetHeight;
// world
world = new World();
// renderer
renderer = new WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
modelContainer.value.appendChild(renderer.domElement);
// camera
camera = new CameraActor(renderer.domElement, width, height);
world.addToWorld(camera);
// stats
if (import.meta.env.MODE === 'development' || IS_DEBUG) {
stats = new Stats();
document.body.appendChild(stats.dom);
}
};
/**
* 更新渲染窗口大小
*/
function updateSize() {
if (!modelContainer.value) return;
const width = modelContainer.value.offsetWidth;
const height = modelContainer.value.offsetHeight;
renderer.setSize(width, height);
camera.getCamera().aspect = width / height;
camera.getCamera().updateProjectionMatrix();
renderer.render(world.getScene(), camera.getCamera());
}
/**
* 动画循环
*/
function animate() {
requestId = requestAnimationFrame(animate);
const deltaTime = clock.getDelta();
// 更新模型动画
world.tickActor(deltaTime);
renderer.render(world.getScene(), camera.getCamera());
// stats状态更新
if (import.meta.env.MODE === 'development' || IS_DEBUG) {
stats && stats.update();
}
}
/**
* 加载模型
* @param url
*/
const loadModel = (url: string) => {
loader.load(
url,
(gltf) => {
const actor = new GltfActor(gltf);
world.addGltfToWorld(actor);
},
() => {},
(error) => {
logger.error('loadModel error:', error);
emit('modelLoadError');
},
);
};
/**
* 渲染模型
* 可以当模型获取完毕后或到达渲染时机时调用
* @param modelUrls 模型URl数组
*/
const render = (modelUrls: string[]) => {
const renderStartTime = performance.now();
try {
const manager = new THREE.LoadingManager(
// Loaded
async () => {
},
// Progress
(itemUrl, itemsLoaded, itemsTotal) => {
const loadedData = Math.floor((itemsLoaded / itemsTotal) * 100);
emit('modelLoaded', loadedData);
},
// error
(error) => {
logger.error('THREE.LoadingManager Error', error);
emit('modelLoadError');
},
);
loader = new GLTFLoader(manager);
modelUrls.forEach((url) => {
loadModel(url);
});
} catch (e) {
logger.error('render ~ error', e);
}
};
/**
* 驱动单个动画
* @param actor
*/
const startActorAnimation = (actor: ActorBase) => {
try {
const driveComponent = actor.getComponentByName('DriveComponent') as DriveComponent;
driveComponent && driveComponent.startEvaluate();
} catch (e) {
console.error(e);
}
};
/**
* 驱动全部动画
* @param actor
*/
const startAnimation = () => {
world.getActors().forEach((actor) => {
startActorAnimation(actor);
});
};
/**
* 重置模型(重置为上一次保存的状态
*/
const reset = () => {
camera.getControls().reset();
};
/**
* 前进/后退
* @param step 前进/后退 步长
*/
const zoomIn = (step = 1) => camera.zoomIn(step);
const zoomOut = (step = 1) => camera.zoomOut(step);
defineExpose({
render,
updateSize,
reset,
zoomIn,
zoomOut,
startAnimation,
});
onMounted(() => {
init();
animate();
window.addEventListener('resize', updateSize, false);
});
onUnmounted(() => {
window.removeEventListener('resize', updateSize, false);
cancelAnimationFrame(requestId);
});
</script>
<style scoped>
.model-container {
width: 100vw;
height: 100vh;
}
</style>
解释几个比较重要的函数
-
init:初始化3D场景
这个方法一般会在onMouted中调用
在World类中已经设置好了灯光等参数
在Camera类中已经设置好了相机参数
-
render:开始渲染模型
这个方法是对外暴露的,可以由业务自己控制模型开始渲染的时机,一般是在获取到模型文件后
渲染过程中会利用THREE.LoadingManager进行渲染进度的监控,并通过emit向业务传递加载进度
-
updateSize:更新渲染窗口
这个方法是对外暴露的,会监听resize,当屏幕窗口改变时会及时更新渲染区域大小,也可以用于全屏展示3D场景的一些需求来调用
-
reset:重置模型
这个方法是对外暴露的,可以由业务在一些需要重置模型到初始视角的时候调用
-
zoomIn:前进
这个方法是对外暴露的,可以用来向世界原点前进一定的距离,距离可传参控制,默认是1m
-
zoomOut:后退
这个方法是对外暴露的,可以用来向世界原点后退一定的距离,距离可传参控制,默认是1m
-
startAnimation:开始动画
这个方法是对外暴露的,可以用来在特定的时机开启动画
如果你的需求仅限于【模型都是glb格式,且只需要无限循环动画】,那么你完全可以直接使用上面这个组件,只需要
- 在你的业务代码中引入其作为你的子组件
- 通过ref调用该组件的render方法,并传递你的模型url数组
- 在合适的时机调用startAnimation开始动画
注意,目前World和CameraActor里初始化的参数是写死的,如果不满足你的业务需求,你可以在这个框架上进行一些重构,例如:
抽离一个CameraBase,然后new一个Camera类继承于CameraBase,在这个新类中配置业务独特的参数。当然你可以有更好的方案设计。
此外,如果你需要更多的功能,你也可以在框架内部进行补充,目前的功能是比较简单的
-
如果你需要更多的动画控制方法
你可以在DriveComponent中,动态的添加,目前支持的功能较少,根据需要你还可以添加
播放第N个动作,播放上一个动作,播放下一个动作,拖动播放,修改帧率等等
-
如果你需要更多的模型操作方法,比如双击移动到某个视角
你可以在Camera类中添加相关的方法
-
如果你的模型格式不是glb,是obj或者fbx格式,或者你有一些texture贴图要渲染
那么你可能需要重新写一个vue组件,或者另外写一个loadModel方法,来适配不同的模型格式
-
如果你的动画不是像glb模型这样,可以利用AnimationMixer控制动画,比如实现骨骼动画
那么你可能需要在你的Actor上挂载新的DriveComponent,然后实现你特定的动画逻辑
-
如果你需要对模型更换皮肤等功能
那么你可以设计一个MeshComponent类挂载到你的Actor上,用于控制换肤等操作 ......