本项目基于 Three.js + Vue3 搭建了一个三维场景交互平台,实现了老人模型在指定路径上漫游、可视化路径编辑、高质量贴图渲染等功能,支持动态路径绘制与动画播放控制。
核心功能模块
初始化 Scene、Camera、Renderer、Controls。
设置 OrbitControls 允许用户缩放、旋转、查看。
加载光照系统(Ambient、Directional、Point Light)保证模型亮度。
加载地板(PlaneGeometry)用于鼠标点击拾取路径点。
支持动态贴图(360度全景图转换为立方体贴图)作为环境背景和反射环境。
通过 GLTFLoader 加载
scene.gltf
场景模型。自动计算模型的包围盒,进行居中、缩放、归一化处理。
动态调整相机位置和控制器范围以适配模型尺寸。
通过 GLTFLoader 加载动画模型
scene.gltf
。使用
AnimationMixer
播放动作(默认选择动画名称为'Take 001'
)。设置缩放比例并将模型加入场景。
鼠标路径点拾取与曲线绘制
onMouseClick()
+updateCurve()
支持点击地板获取点击位置坐标,生成路径点。
使用
CatmullRomCurve3
创建平滑曲线。将路径可视化为绿色线段,同时保留路径点球体标识。
自动更新路径线(上一次路径被销毁释放资源)
鼠标路径点拾取与曲线绘制
onMouseClick()
+updateCurve()
支持点击地板获取点击位置坐标,生成路径点。
使用
CatmullRomCurve3
创建平滑曲线。将路径可视化为绿色线段,同时保留路径点球体标识。
自动更新路径线(上一次路径被销毁释放资源)
沿路径自动漫游动画
animate()
使用
getPointAt(t)
计算路径位置,oldMan.position.copy()
实现移动。使用
lookAt(nextPos)
保证朝向平滑过渡。结合 Tween.js 实现更复杂的平滑移动也预留了空间(tweenGroup)。
功能类别 描述 ✅ 场景搭建 Renderer + Camera + Scene + Controls 标准架构,支持自适应大小 ✅ 模型加载 支持场景模型和动画人物模型的 GLTF 加载、归一化、居中 ✅ 动画系统 支持 AnimationMixer 播放 GLTF 动画 ✅ 路径绘制 支持点击拾取路径点,自动生成 CatmullRom 曲线并渲染 ✅ 人物漫游 沿着曲线路径自动移动,支持方向自动面向下一个点 ✅ 环境贴图 360° equirectangular 环境贴图转立方体贴图,提升渲染质量 ✅ 响应式支持 自适应窗口尺寸变动,支持窗口重绘 ✅ 性能优化 清理旧资源(曲线材质与几何体)、帧同步动画、透明地板遮盖
<template>
<div ref="threeCanvas" style="width: 100%; height: 100%" id="c"></div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import * as THREE from 'three';
import { Tween, Group } from '@tweenjs/tween.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
//catmullRomcurve3生成平滑的曲线路径
import { CatmullRomCurve3 } from 'three';
const threeCanvas = ref(null);
// 初始化renderer,scene、camera、controls
let renderer, scene, camera, controls;
// 用来控制时间的控制开始播放动画时,Clock 开始“计时从上次更新到现在过了多少秒?” 它会告诉你一个 delta(增量)值elta 可以用来控制物体运动的速度
const clock = new THREE.Clock();
// 控制模型动画播放
let mixer = null;
// 老人模型对象
let oldMan = null;
// 管理动画播放的组 像是一个文件夹里面可以放很多 3D 物体你给这个文件夹移动、旋转、缩放,里面所有的物体都会一起动
const tweenGroup = new Group();
let model = null; // 场景模型
// 用于鼠标拾取射线检测
// 监听鼠标事件,得到鼠标的像素坐标(例如X=500,Y=300)
// 把这个像素坐标转换成mouse变量的规范化坐标(-1到1)
// 用 raycaster 从摄像机位置往 mouse 指定方向射出一条射线
// 检测这条射线碰到了哪些模型(model)
// 返回碰到的模型,告诉你“你点中了它”
const raycaster = new THREE.Raycaster();
// 储存鼠标屏幕坐标
const mouse = new THREE.Vector2();
// const pathPoints = [
// new THREE.Vector3(6.4196, -1.5316, 2.1559),
// new THREE.Vector3(6.4171, -1.5316, 1.6201),
// new THREE.Vector3(6.4298, -1.5316, 1.5201),
// new THREE.Vector3(6.4068, -1.5316, 1.2091),
// new THREE.Vector3(6.4208, -1.5316, 0.9920),
// new THREE.Vector3(6.4490, -1.5316, 0.6572),
// new THREE.Vector3(6.3904, -1.5316, 0.2512),
// new THREE.Vector3(6.4287, -1.5316, 0.0442),
// new THREE.Vector3(6.4705, -1.5316, -0.2997),
// new THREE.Vector3(6.4657, -1.5316, -0.4175),
// new THREE.Vector3(6.4467, -1.5316, -0.5084),
// new THREE.Vector3(6.4344, -1.5316, -0.6307),
// new THREE.Vector3(4.7067, -1.5316, -0.7048),
// new THREE.Vector3(4.2222, -1.5316, -0.7167),
// new THREE.Vector3(3.5638, -1.5316, -0.7867),
// new THREE.Vector3(2.5262, -1.5316, -0.9241),
// new THREE.Vector3(1.3211, -1.5316, -0.9406),
// new THREE.Vector3(0.7452, -1.5254, -1.6667),
// new THREE.Vector3(0.8206, -1.5316, -3.2251),
// new THREE.Vector3(0.7029, -1.1531, -3.5734),
// new THREE.Vector3(0.6420, -1.1592, -3.4076),
// new THREE.Vector3(0.3622, -1.5317, -4.1282),
// new THREE.Vector3(0.3225, -1.5317, -4.9323),
// new THREE.Vector3(0.2781, -1.5317, -4.8699),
// new THREE.Vector3(0.2801, -1.5317, -4.7799),
// new THREE.Vector3(0.2710, -1.5317, -4.6158),
// new THREE.Vector3(0.2277, -1.5317, -4.4683),
// new THREE.Vector3(0.1468, -1.5317, -4.2639),
// new THREE.Vector3(0.0767, -1.5317, -4.1329),
// new THREE.Vector3(-0.0036, -1.5316, -3.9601),
// new THREE.Vector3(-0.3682, -1.5316, -3.7575),
// new THREE.Vector3(-0.3908, -1.5316, -3.7285),
// new THREE.Vector3(-0.5117, -1.5316, -3.7072),
// new THREE.Vector3(-0.4032, -1.5316, -3.5493),
// new THREE.Vector3(-0.2820, -1.5316, -3.4594),
// new THREE.Vector3(-0.0769, -1.5316, -3.2622),
// new THREE.Vector3(0.1811, -1.5316, -3.1384),
// new THREE.Vector3(0.3936, -1.5316, -3.1541),
// new THREE.Vector3(0.5692, -1.5316, -2.8172),
// new THREE.Vector3(0.5906, -1.5254, -2.5948),
// new THREE.Vector3(0.6138, -1.5254, -2.2521),
// new THREE.Vector3(0.6698, -1.5254, -1.9111),
// new THREE.Vector3(0.7027, -1.5254, -1.5886),
// new THREE.Vector3(0.7017, -1.5254, -1.5627),
// new THREE.Vector3(0.8371, -1.5254, -1.4829),
// new THREE.Vector3(0.8549, -1.5254, -1.3437),
// new THREE.Vector3(0.9120, -1.5316, -1.2007),
// new THREE.Vector3(0.9152, -1.2936, -1.0072),
// new THREE.Vector3(0.9257, -1.2940, -0.9854),
// new THREE.Vector3(1.0052, -1.2760, -0.9851),
// new THREE.Vector3(2.3214, -1.5316, -0.9415),
// new THREE.Vector3(2.5338, -1.5316, -0.9500),
// new THREE.Vector3(3.0566, -1.5316, -0.9201),
// new THREE.Vector3(3.1610, -1.4302, -2.6449),
// new THREE.Vector3(3.1557, -1.5217, -3.4080),
// new THREE.Vector3(3.1883, -1.5217, -3.9457),
// new THREE.Vector3(2.7172, -1.5217, -4.0546),
// new THREE.Vector3(2.2665, -1.5217, -4.0999),
// new THREE.Vector3(2.3441, -1.5317, -4.8175),
// new THREE.Vector3(1.7710, -0.2167, -4.0579),
// new THREE.Vector3(2.1026, -1.5317, -4.4224),
// new THREE.Vector3(2.1063, -1.5317, -4.2882),
// new THREE.Vector3(2.1253, -1.5317, -4.2743),
// new THREE.Vector3(2.2161, -1.5317, -4.1844),
// new THREE.Vector3(2.4674, -0.9122, -4.0579),
// new THREE.Vector3(2.6245, -1.1370, -4.0619),
// new THREE.Vector3(2.8020, -1.3726, -4.0619),
// new THREE.Vector3(2.8789, -1.5217, -3.9944),
// new THREE.Vector3(2.9393, -1.5217, -3.8445),
// new THREE.Vector3(2.9765, -1.5218, -3.6816),
// new THREE.Vector3(3.0671, -1.5217, -3.0895),
// new THREE.Vector3(3.0776, -1.5227, -2.8732),
// new THREE.Vector3(2.9843, -1.5217, -2.5719),
// new THREE.Vector3(3.0975, -1.5217, -2.4632),
// new THREE.Vector3(3.2010, -1.0086, -2.2587),
// new THREE.Vector3(3.2422, -1.0207, -2.1944),
// new THREE.Vector3(3.2743, -0.9951, -2.1142),
// new THREE.Vector3(3.3982, -1.4303, -2.1759),
// new THREE.Vector3(3.0181, -1.5316, -1.0897),
// new THREE.Vector3(3.1544, -1.5316, -0.9389),
// new THREE.Vector3(4.7340, -1.5316, -0.9179),
// new THREE.Vector3(4.8870, -1.0513, -1.0009),
// new THREE.Vector3(5.9282, -1.5316, -1.3546),
// new THREE.Vector3(6.4366, -1.5316, -1.7501),
// new THREE.Vector3(6.8494, -0.9751, -2.0724),
// new THREE.Vector3(7.6253, -1.5199, -2.7929),
// new THREE.Vector3(7.4687, -1.5199, -3.5638),
// new THREE.Vector3(7.3276, -1.5199, -3.9571),
// new THREE.Vector3(7.0754, -1.5317, -4.3921),
// new THREE.Vector3(5.8808, 0.5383, -3.9948)
// ];
// 鼠标点击采集的路径点
let curve = null;
const pathPoints = [
new THREE.Vector3(6.319822, -1.53, 2.570512),
new THREE.Vector3(6.406049, -1.53, 2.164301),
new THREE.Vector3(6.359590, -1.53, 2.039799),
new THREE.Vector3(6.443645, -1.53, 1.595450),
new THREE.Vector3(6.352108, -1.53, 1.354298),
new THREE.Vector3(6.391970, -1.53, 0.996318),
new THREE.Vector3(6.433369, -1.53, 0.818301),
new THREE.Vector3(6.430238, -1.53, 0.583670),
new THREE.Vector3(6.427887, -1.53, 0.401656),
new THREE.Vector3(6.423045, -1.53, 0.021679),
new THREE.Vector3(6.438557, -1.53, -0.426365),
new THREE.Vector3(6.435172, -1.53, -0.684745),
new THREE.Vector3(6.705428, -1.53, -0.858215),
new THREE.Vector3(7.182494, -1.53, -0.869206),
new THREE.Vector3(7.559753, -1.53, -0.875711),
new THREE.Vector3(7.720775, -1.53, -1.036134),
new THREE.Vector3(7.679018, -1.53, -1.771417),
new THREE.Vector3(7.679299, -1.53, -2.556160),
new THREE.Vector3(7.561710, -1.53, -3.398952),
new THREE.Vector3(7.422641, -1.53, -3.881184),
new THREE.Vector3(6.792596, -1.53, -3.856359),
new THREE.Vector3(6.216405, -1.53, -3.869296),
new THREE.Vector3(6.258762, -1.53, -3.603863),
new THREE.Vector3(6.079989, -1.53, -3.468030),
new THREE.Vector3(5.963924, -1.53, -3.793249),
new THREE.Vector3(5.719277, -1.53, -3.779842),
new THREE.Vector3(5.319093, -1.53, -3.748659),
new THREE.Vector3(4.677778, -1.53, -3.702489),
new THREE.Vector3(4.412901, -1.53, -3.611398),
new THREE.Vector3(4.292174, -1.53, -3.246777),
new THREE.Vector3(4.070667, -1.53, -2.918138),
new THREE.Vector3(4.044400, -1.53, -2.482111),
new THREE.Vector3(3.984729, -1.53, -1.835359),
new THREE.Vector3(3.995187, -1.53, -1.318142),
new THREE.Vector3(4.059139, -1.53, -0.980950),
new THREE.Vector3(4.143814, -1.53, -0.617863),
new THREE.Vector3(3.349554, -1.53, -0.824308),
new THREE.Vector3(3.043609, -1.53, -0.543691),
new THREE.Vector3(2.488461, -1.53, -0.440182),
new THREE.Vector3(2.009007, -1.53, -0.479957),
new THREE.Vector3(1.568039, -1.53, -0.374817),
new THREE.Vector3(1.358505, -1.53, -0.426029),
new THREE.Vector3(0.633736, -1.53, -0.400807),
new THREE.Vector3(0.313731, -1.53, -0.553517),
new THREE.Vector3(0.509338, -1.53, -1.300437),
new THREE.Vector3(0.507492, -1.53, -2.494333),
new THREE.Vector3(-0.112694, -1.53, -5.047881),
new THREE.Vector3(0.368581, -1.53, -4.402690),
new THREE.Vector3(0.563918, -1.53, -3.301067),
new THREE.Vector3(0.755048, -1.53, -2.809569),
new THREE.Vector3(0.926919, -1.53, -1.707784),
new THREE.Vector3(0.915208, -1.53, -0.997344),
new THREE.Vector3(1.102943, -1.53, -0.880010),
new THREE.Vector3(1.595748, -1.53, -0.692275),
new THREE.Vector3(2.112019, -1.53, -0.856543),
new THREE.Vector3(2.745625, -1.53, -0.880010),
new THREE.Vector3(3.237771, -1.53, -0.841401),
new THREE.Vector3(3.228425, -1.53, -1.573625),
new THREE.Vector3(3.231418, -1.53, -1.926240),
new THREE.Vector3(2.977760, -1.53, -2.492462),
new THREE.Vector3(2.983663, -1.53, -3.198455),
new THREE.Vector3(2.963501, -1.53, -3.598899),
new THREE.Vector3(2.659824, -1.53, -3.836541),
new THREE.Vector3(2.191105, -1.53, -4.028251),
new THREE.Vector3(2.240390, -1.53, -4.310693),
new THREE.Vector3(2.572504, -1.53, -4.661991),
new THREE.Vector3(2.307537, -1.53, -3.886087),
new THREE.Vector3(3.027315, -1.53, -2.798007),
new THREE.Vector3(3.300683, -1.53, -1.784693),
new THREE.Vector3(3.619300, -1.53, -0.607687),
new THREE.Vector3(4.113084, -1.53, -0.698215),
new THREE.Vector3(4.958174, -1.53, -0.692387),
new THREE.Vector3(5.310518, -1.53, -0.713442),
new THREE.Vector3(5.592218, -1.53, -0.711499),
new THREE.Vector3(5.923636, -1.53, -0.991075),
new THREE.Vector3(6.302977, -1.53, -1.168141),
new THREE.Vector3(6.393383, -1.53, -1.641758),
];
// 就是把点转换成路径曲线
let t = 0; // 曲线上位置参数
const speed = 0.0005; // 人物移动速度
// 保留之前的环境贴图加载方法
const loadEnvironment = async () => {
// 初始化加载器
const loader = new THREE.TextureLoader();
// 加载照片
const texture = await loader.loadAsync('360.jpg');
//承载立方体贴图的六个面
const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(texture.image.height);
//将加载好的 equirectangular(等距矩形)纹理 转换为 立方体贴图(CubeMap)
cubeRenderTarget.fromEquirectangularTexture(renderer, texture);
//设置立方体贴图为整个场景的 背景贴图
scene.background = cubeRenderTarget.texture;
//设置立方体贴图为 物体的环境贴图(Environment Map),用于真实的反射和光照计算
scene.environment = cubeRenderTarget.texture;
};
let floor; // 全局声明地板对象
// 初始化场景
const initScene = () => {
//场景
scene = new THREE.Scene();
//相机
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 1.5, 15);
//开启抗锯齿,允许使用透明背景
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
//设置渲染器的输出画布尺寸为当前窗口大小:window.innerWidth:浏览器可视区域的宽度window.innerHeight:浏览器可视区域的高度
renderer.setSize(window.innerWidth, window.innerHeight);
//将渲染器的输出画布添加到页面中,让其显示出来就是ref="threeCanvas"对应的元素中
threeCanvas.value.appendChild(renderer.domElement);
//轨道控制器允许鼠标控制 摄像机轨道运动
controls = new OrbitControls(camera, renderer.domElement);
//设置摄像机与目标点(默认是 [0, 0, 0])之间的最小距离。
controls.minDistance = 1;
//设置摄像机与目标点的最大距离,限制用户不能缩放太远。
controls.maxDistance = 500;
//设置为 Math.PI,表示用户可以把摄像机从最上面旋转到最下面,没有垂直方向的限制
controls.maxPolarAngle = Math.PI;
const lights = [
new THREE.AmbientLight(0xffffff, 0.6),
new THREE.DirectionalLight(0xffffff, 0.8),
new THREE.DirectionalLight(0xffffff, 0.4),
new THREE.PointLight(0xffffff, 0.5)
];
lights[1].position.set(5, 10, 7);
lights[2].position.set(-5, 5, -5);
lights[3].position.set(0, 3, 3);
//将上述所有灯光统一添加到 scene 场景中,才能在渲染时生效。
lights.forEach(light => scene.add(light));
// 创建一个大地板
const floorGeometry = new THREE.PlaneGeometry(20, 20);
const floorMaterial = new THREE.MeshStandardMaterial({
color: 0x999999,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
});
floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2; // 地板水平放置
floor.position.y = -1.53; // 对齐你的点击点 y 坐标
floor.name = 'Floor';
floor.receiveShadow = true;
scene.add(floor);
// **调用环境贴图加载**
loadEnvironment();
};
// 加载场景模型
const loadModel = () => {
const loader = new GLTFLoader();
loader.setPath('/models1/');
loader.load('scene.gltf', (gltf) => {
model = gltf.scene;
// 计算包围盒
const box = new THREE.Box3().setFromObject(model);
console.log("box", box);
const center = new THREE.Vector3();
box.getCenter(center);
// 移动模型到原点
model.position.x -= center.x;
model.position.y -= center.y;
model.position.z -= center.z;
// 缩放模型
const size = new THREE.Vector3();
box.getSize(size);
const maxAxis = Math.max(size.x, size.y, size.z);
const scaleFactor = 10 / maxAxis;
model.scale.setScalar(scaleFactor);
scene.add(model);
// 调整摄像机位置和轨道控制范围
camera.position.set(0, size.y * scaleFactor * 1.5, size.z * scaleFactor * 2.5);
controls.minDistance = maxAxis * scaleFactor * 0.5;
controls.maxDistance = maxAxis * scaleFactor * 10;
}, undefined, (error) => {
console.error('加载场景模型失败:', error);
});
};
// 加载老人模型并准备动画
const loadOldManModel = () => {
const loader = new GLTFLoader();
loader.setPath('/oldmodels/');
loader.load('scene.gltf', (gltf) => {
oldMan = gltf.scene;
oldMan.scale.set(0.01, 0.01, 0.01);
oldMan.name = 'OldMan';
oldMan.position.set(0, 0, 0);
scene.add(oldMan);
if (gltf.animations && gltf.animations.length > 0) {
//AnimationMixer 是 Three.js 专门用于管理动画播放的对象。它必须绑定到一个 Object3D(一般是模型的 scene)上。这个 mixer 后续用于播放、暂停、切换等动画控制。注意:一个模型对应一个 AnimationMixer,多个模型需创建多个 mixer。
mixer = new THREE.AnimationMixer(oldMan);
//从动画列表中查找名称为 'Take 001' 的动画剪辑如果没找到 'Take 001',就使用第一个动画(gltf.animations[0])作为备选
const shuffleClip = THREE.AnimationClip.findByName(gltf.animations, 'Take 001') || gltf.animations[0];
//mixer 中获取这个动画剪辑对应的 动画动作对象(Action
const action = mixer.clipAction(shuffleClip);
action.play();
}
}, undefined, (error) => {
console.error('加载老人模型失败:', error);
});
};
// 鼠标点击拾取模型表面路径点
function onMouseClick(event) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = - ((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(floor); // 只检测地板
if (intersects.length > 0) {
const point = intersects[0].point.clone();
console.log('地板点击位置:', point);
pathPoints.push(point);
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.05, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xff0000 })
);
sphere.position.copy(point);
scene.add(sphere);
updateCurve();
t = 0;
}
}
// 更新曲线及其可视化
function updateCurve() {
if (pathPoints.length < 2) {
curve = null;
return;
}
curve = new CatmullRomCurve3(pathPoints, false); // false表示路径不闭合
// 移除旧路径线
const oldLine = scene.getObjectByName('pathLine');
if (oldLine) {
scene.remove(oldLine);
oldLine.geometry.dispose();
oldLine.material.dispose();
}
const points = curve.getPoints(50);
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: 0x00ff00 ,transparent: true,
opacity: 0 });
const pathLine = new THREE.Line(geometry, material);
pathLine.name = 'pathLine';
scene.add(pathLine);
}
// 动画循环
const animate = (time) => {
time *= 0.001;
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (mixer) mixer.update(delta);
tweenGroup.update(time * 1000);
controls.update();
// 沿曲线移动老人模型
if (curve && oldMan) {
t += speed;
if (t > 1) t = 0;
const pos = curve.getPointAt(t);
const nextPos = curve.getPointAt((t + 0.01) % 1);
//把机器人放到“路径上 t 这个位置”的点。
oldMan.position.copy(pos);
oldMan.lookAt(nextPos);
}
renderer.render(scene, camera);
};
// 监听窗口尺寸变化
const handleResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
// 生命周期钩子
onMounted(() => {
initScene();
loadModel();
loadOldManModel();
animate();
updateCurve(); // 初始化路径
window.addEventListener('resize', handleResize);
// window.addEventListener('click', onMouseClick);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
// window.removeEventListener('click', onMouseClick);`
renderer.dispose();
controls.dispose();
});
</script>
<style scoped>
#c {
width: 100%;
height: 100%;
display: block;
}
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>