一文详解:用 Three.js + Vue 3 实现 3D 商品展示(从入门到工程级实践)
下面是一套可直接落地的指南,包含项目搭建、核心组件代码(完整 ThreeViewer.vue)、模型加载、材质/环境/光照、交互(旋转、缩放、标注/热点、拾取)、性能优化(Draco/KTX2、LODs、延迟加载)、导出与调试建议,以及与 Vue 响应式/props 的整合方式。代码适用于 Vite + Vue 3(Composition API)。
1. 准备工作(项目与依赖)
推荐用 Vite + Vue 3:
npm init vite@latest three-vue -- --template vue
cd three-vue
npm install
安装 Three.js 相关依赖(核心 + loaders + controls + postprocessing):
npm i three
npm i three-stdlib # 包含常见扩展(或单独安装 loaders)
# 或单独安装需要的 loader 包
npm i three/examples/jsm/loaders/GLTFLoader
(注:某些环境下 three-stdlib 更方便;我下面示例使用 three 自带 examples 模块路径。)
如需 Draco / KTX2 压缩支持(强烈建议用于生产):
npm i three @loaders.gl/core @loaders.gl/gltf
# 或仅安装 draco 解码器二进制(在示例中我们用 three 的 DRACOLoader)
2. 功能点设计(需求一览)
- 加载
.glb/.gltf产品模型(支持 Draco 压缩) - PBR 材质 + HDR 环境(PMREM)渲染
- OrbitControls(鼠标旋转/缩放/平移)
- 动画支持(若模型含 Animation)
- Hotspots(热点/标注),并支持点击显示详情
- 拾取(Raycaster)用于选中模型部位
- 截图 / 分享
- 性能优化(纹理压缩、预处理、LOD)
- 响应式(组件 props 更新模型 / 替换材质)
3. 关键实现:ThreeViewer.vue(完整代码)
这是一个工程级可复用组件,内嵌常见功能:GLTF 加载(含 Draco)、PMREM 环境、OrbitControls、动画 Mixer、热点、拾取、resize、销毁等。
<template>
<div class="three-viewer" ref="container">
<!-- 可插入自定义 UI -->
<div class="ui-top">
<button @click="resetView">重置视角</button>
<button @click="toggleAutoRotate">{{ autoRotate ? '停止自动转动' : '自动旋转' }}</button>
<button @click="screenshot">截图</button>
</div>
<!-- 热点示例(由 Three.js 的 Raycaster 驱动,展示在 DOM) -->
<div v-for="(hot, i) in visibleHotspots" :key="i"
class="hotspot"
:style="{ left: hot.screenX + 'px', top: hot.screenY + 'px' }"
@click.stop="onHotspotClick(hot)">
{{ hot.label }}
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; // 可选,用于调试
// ---------- Props (可外部传入) ----------
/**
* Props 建议在父组件传入,这里用常量模拟,
* 你可以改成 defineProps({ modelUrl: String, etc... })
*/
const props = {
modelUrl: undefined // 用 watch 或 defineProps 接收
};
// 如果在真实组件中使用,换成:
/*
const props = defineProps({
modelUrl: { type: String, required: true },
envUrl: { type: String, default: null }, // HDR 环境
backgroundColor: { type: String, default: '#ffffff' }
});
*/
// ---------- Refs ----------
const container = ref(null);
// 状态
const autoRotate = ref(false);
const loadedModel = ref(null);
const mixer = ref(null);
const animations = ref([]);
const hotspots = ref([]); // [{ position: new THREE.Vector3(x,y,z), label }]
const visibleHotspots = ref([]); // 用于 DOM 展示热点位置
// Three core
let scene, camera, renderer, controls, pmremGenerator, clock;
let raycaster, mouse;
// ---------- 初始化 ----------
onMounted(async () => {
initThree();
await loadEnvironment(); // 可选:HDRI 环境
await loadModel(props.modelUrl || '/models/product.glb'); // 替换你的模型路径
animate();
window.addEventListener('resize', onWindowResize);
container.value.addEventListener('pointermove', onPointerMove);
container.value.addEventListener('click', onPointerClick);
});
// 清理
onBeforeUnmount(() => {
stopAnimation();
window.removeEventListener('resize', onWindowResize);
container.value.removeEventListener('pointermove', onPointerMove);
container.value.removeEventListener('click', onPointerClick);
disposeAll();
});
// ---------- Three 初始化函数 ----------
function initThree() {
const el = container.value;
const rect = el.getBoundingClientRect();
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf5f5f5);
camera = new THREE.PerspectiveCamera(45, rect.width / rect.height, 0.1, 2000);
camera.position.set(0, 1, 3);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(rect.width, rect.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.physicallyCorrectLights = true;
el.appendChild(renderer.domElement);
// OrbitControls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 0.5;
controls.maxDistance = 10;
controls.autoRotate = autoRotate.value;
controls.autoRotateSpeed = 1.0;
// Light
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
hemi.position.set(0, 2, 0);
scene.add(hemi);
const spot = new THREE.DirectionalLight(0xffffff, 1.2);
spot.position.set(5, 10, 7.5);
spot.castShadow = true;
spot.shadow.bias = -0.0001;
spot.shadow.mapSize.width = 2048;
spot.shadow.mapSize.height = 2048;
scene.add(spot);
clock = new THREE.Clock();
// Raycaster for interaction
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
// PMREM generator will be created when env is loaded (in loadEnvironment)
}
// ---------- 加载 HDR 环境照明(可选) ----------
async function loadEnvironment() {
// 如果有 HDRI,用 RGBELoader + PMREMGenerator
// const rgbe = new RGBELoader();
// const hdr = await rgbe.loadAsync('/hdr/studio_small_08_2k.hdr');
// pmremGenerator = new THREE.PMREMGenerator(renderer);
// pmremGenerator.compileEquirectangularShader();
// const envMap = pmremGenerator.fromEquirectangular(hdr).texture;
// scene.environment = envMap;
// scene.background = envMap; // 可选
// hdr.dispose();
// 注意:若使用 Core WebGL with Three.js, 用 glb + KTX2 压缩会更好。
}
// ---------- GLTF 加载(含 Draco 支持) ----------
async function loadModel(url) {
if (!url) return;
// loader setup
const loader = new GLTFLoader();
// 如果使用 Draco 压缩:需要把解码器文件放在 public/draco/ 并设置路径
// const dracoLoader = new DRACOLoader();
// dracoLoader.setDecoderPath('/draco/');
// loader.setDRACOLoader(dracoLoader);
try {
const gltf = await loader.loadAsync(url);
// 如果已有旧模型,先 dispose
if (loadedModel.value) {
scene.remove(loadedModel.value);
disposeHierarchy(loadedModel.value);
}
const root = gltf.scene || gltf.scenes[0];
// 把模型缩放到合理大小并居中(常用)
centerAndScaleToUnit(root);
// 材质处理:强制 sRGB / 正确的编码
root.traverse((node) => {
if (node.isMesh) {
node.castShadow = true;
node.receiveShadow = true;
if (node.material) {
node.material.side = THREE.FrontSide;
if (node.material.map) node.material.map.encoding = THREE.sRGBEncoding;
// 启用双面或透光视情况而定
// node.material.needsUpdate = true;
}
}
});
scene.add(root);
loadedModel.value = root;
// 动画处理
if (gltf.animations && gltf.animations.length) {
mixer.value = new THREE.AnimationMixer(root);
animations.value = gltf.animations;
animations.value.forEach((clip) => mixer.value.clipAction(clip).play());
}
// 示例 Hotspots:可从模型 metadata 或另外配置加载
hotspots.value = [
{ position: new THREE.Vector3(0.2, 0.6, 0.1), label: '按钮A', id: 'btnA' },
{ position: new THREE.Vector3(-0.3, 0.4, 0.2), label: 'Logo', id: 'logo' }
];
} catch (err) {
console.error('GLTF load error:', err);
}
}
// ---------- 工具函数:把模型缩放到单位盒子并居中 ----------
function centerAndScaleToUnit(object3d) {
const box = new THREE.Box3().setFromObject(object3d);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1.0 / maxDim; // 缩放到最大边为 1
object3d.scale.setScalar(scale);
// 重新计算并居中
box.setFromObject(object3d);
const center = new THREE.Vector3();
box.getCenter(center);
object3d.position.x += (object3d.position.x - center.x);
object3d.position.y += (object3d.position.y - center.y);
object3d.position.z += (object3d.position.z - center.z);
}
// ---------- 渲染与动画循环 ----------
let rafId = null;
function animate() {
rafId = requestAnimationFrame(animate);
const dt = clock.getDelta();
if (mixer.value) mixer.value.update(dt);
controls.update();
// 更新热点屏幕坐标(用于 DOM overlay)
updateHotspotScreens();
renderer.render(scene, camera);
}
function stopAnimation() {
if (rafId) cancelAnimationFrame(rafId);
}
// ---------- Resize ----------
function onWindowResize() {
const el = container.value;
const w = el.clientWidth || el.offsetWidth;
const h = el.clientHeight || el.offsetHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
// ---------- Hotspots:从 3D 坐标转换为屏幕坐标 ----------
function updateHotspotScreens() {
if (!loadedModel.value) return;
const rect = container.value.getBoundingClientRect();
visibleHotspots.value = hotspots.value.map(h => {
const pos = h.position.clone();
loadedModel.value.localToWorld(pos); // 转为世界坐标
const proj = pos.clone().project(camera);
// 屏幕坐标
const screenX = (proj.x * 0.5 + 0.5) * rect.width;
const screenY = (-proj.y * 0.5 + 0.5) * rect.height;
return { ...h, screenX, screenY, visible: proj.z < 1 && proj.z > -1 };
}).filter(h => h.visible);
}
// ---------- 鼠标拾取 / Raycasting ----------
function onPointerMove(e) {
const rect = container.value.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
}
function onPointerClick(e) {
// 在点击时发射射线
const rect = container.value.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(loadedModel.value, true);
if (intersects.length > 0) {
const it = intersects[0];
console.log('点击到了模型的网格:', it.object.name, it.point, it.face);
// 触发交互,比如显示面/部件详情或高亮
highlightMesh(it.object);
}
}
// 高亮示例(简单变更材质色调)
function highlightMesh(mesh) {
if (!mesh || !mesh.material) return;
const oldMat = mesh.material;
mesh.material = oldMat.clone();
mesh.material.emissive = new THREE.Color(0x444444);
setTimeout(() => {
if (mesh.material) mesh.material.dispose();
mesh.material = oldMat;
}, 600);
}
// 点击热点处理
function onHotspotClick(hot) {
alert('点击了热点:' + hot.label);
}
// ---------- 截图 ----------
function screenshot() {
renderer.render(scene, camera);
const data = renderer.domElement.toDataURL('image/png');
const a = document.createElement('a');
a.href = data;
a.download = 'screenshot.png';
a.click();
}
// ---------- 重置视角 ----------
function resetView() {
controls.reset();
camera.position.set(0, 1, 3);
controls.update();
}
// 切换自动旋转
function toggleAutoRotate() {
autoRotate.value = !autoRotate.value;
controls.autoRotate = autoRotate.value;
}
// ---------- 清理函数:释放几何体/材质/纹理 ----------
function disposeHierarchy(node) {
node.traverse((n) => {
if (n.geometry) {
n.geometry.dispose();
}
if (n.material) {
if (Array.isArray(n.material)) {
n.material.forEach(mat => {
disposeMaterial(mat);
});
} else {
disposeMaterial(n.material);
}
}
});
}
function disposeMaterial(mat) {
if (!mat) return;
for (const key in mat) {
const value = mat[key];
if (value && value.isTexture) {
value.dispose();
}
}
mat.dispose();
}
function disposeAll() {
stopAnimation();
if (mixer.value) mixer.value.uncacheRoot(loadedModel.value);
disposeHierarchy(scene);
if (renderer) {
renderer.dispose();
if (renderer.domElement && renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
}
if (pmremGenerator) {
pmremGenerator.dispose();
}
}
</script>
<style scoped>
.three-viewer {
width: 100%;
height: 600px;
position: relative;
overflow: hidden;
border-radius: 8px;
background: #f5f5f5;
}
.three-viewer .ui-top {
position: absolute;
left: 12px;
top: 12px;
z-index: 20;
display: flex;
gap: 8px;
}
.hotspot {
position: absolute;
transform: translate(-50%, -50%);
z-index: 10;
pointer-events: auto;
padding: 6px 8px;
background: rgba(255,255,255,0.9);
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
cursor: pointer;
font-size: 13px;
}
</style>
说明(快速):
- 组件会在
onMounted初始化 Three、加载模型并开启渲染循环。 - 使用
GLTFLoader加载.glb。如需 Draco 压缩,解除注释并把解码器放到 public/draco/。 centerAndScaleToUnit用于把模型标准化,方便不同模型展示一致的视角。- 热点用
hotspots数据(3D 坐标)通过摄像机投影转为屏幕坐标,在 DOM 中渲染(可用 CSS2DRenderer 代替以获得更平滑效果)。 - 点击拾取用了 Raycaster,并提供
highlightMesh演示高亮逻辑。 dispose在卸载时释放 GPU 资源,避免内存泄漏。
4. 导出模型建议(Blender → GLB;最佳实践)
- 导出格式:首选
.glb(二进制 glTF)——体积小,加载简单。 - 优化:
- 在 Blender 中合并不需单独交互的小物件,减少 draw calls。
- 为常用纹理生成 mipmaps;保证 BaseColor 使用 sRGB。
- 为法线贴图使用 Non-Color(线性)。
- 给模型生成第二套 UV(lightmap)若做静态光照贴图。
- 使用 Draco 压缩 导出 glTF(大幅减小顶点/索引体积)。
- 使用 KTX2/Basis 压缩纹理(兼容性好、加载快)。
- 去掉未使用的顶点/形态键/骨骼。
- LOD:在 Blender 中导出不同细节级别的模型或使用 glTF 的扩展来表示 LOD。
5. 性能优化要点(生产级)
- 压缩传输:
- Draco 压缩顶点(glTF + Draco)
- Basis/KTX2 用于纹理(比 JPEG/PNG 更省带宽)
- 减 draw calls:
- 合并网格(相同材质合并)
- 尽量复用材质与纹理
- 限制像素比:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));或更保守
- 按需加载/延迟加载:
- 初始只加载主视图模型;细节部件/高分纹理在需要时懒加载
- GPU 友好:
- 使用 PBR 标准材质(MeshStandardMaterial),减少自定义 shader(除非必要)
- 避免在每帧创建/销毁对象
- Batch / Instancing:
- 同类重复对象(配件、按钮)使用
InstancedMesh
- 同类重复对象(配件、按钮)使用
- 避免控制流扁平化?(与 obfuscation 无关,此处略)
6. 交互增强(进阶功能)
- CSS2DRenderer / CSS3DRenderer:如果希望渲染 HTML 标注而非 Canvas 上的像素,Three.js 提供
CSS2DRenderer,更方便与 Vue DOM 协作。 - 基于物理的点击区域:你可以提前在模型中标注空的骨骼或命名网格(例如
hotspot_btnA),在加载后直接通过scene.getObjectByName('hotspot_btnA')获取位置。 - 动画控制器:用
AnimationMixer实现播放/暂停/seek,并把控制器绑定到 UI。 - 视角预设:保存若干相机位置(远景、特写、细节视角)并提供 UI 切换。
- 材料替换与试色:通过遍历网格并替换
material.color/map实现换色或更换纹理试装功能。 - AR / WebXR:Three.js 支持 WebXR。若要在移动端尝试 AR 展示,可启用
renderer.xr.enabled = true并使用ARButton(注意需 HTTPS 与兼容设备)。
7. 调试与开发技巧
- 在开发时用
GUI(lil-gui)动态调整光照 / exposure / envIntensity,快速调试场景效果。 - 在 fragment shader 中临时输出
vUv或法线颜色以检查 UV/法线是否正确:gl_FragColor = vec4(abs(normalize(vNormal)), 1.0); - 若贴图看起来偏暗/偏亮,检查纹理
encoding:map.encoding = THREE.sRGBEncoding;metalness/roughness/normal保持线性
- 若遇到缝隙/图集污染,在 atlas 中设置 padding 或在材质采样处用
nearest(trade-off)或确保 mipmap 不跨 tile。
8. 与 Vue 响应式结合(props 更新、销毁、事件)
- 用
watch监听modelUrl:当父组件传入新模型时,调用loadModel(newUrl)替换模型并释放旧资源。 - 事件回调:在组件上暴露事件(
emit('picked', info))用于父组件响应用户操作。 - 保持单例 renderer:避免在短时间内频繁创建与销毁 renderer(会导致内存波动),优先复用组件或做资源池。
示例(在 setup 中):
const emit = defineEmits(['picked']);
function onPointerClick(e) {
// ...射线检测
if (intersects.length) {
emit('picked', { object: intersects[0].object.name, point: intersects[0].point });
}
}
9. 常见问题与解决方案(FAQ)
Q:模型加载很慢怎么办?
A:启用 Draco + KTX2,使用服务器开启 gzip/brotli,预加载小尺寸预览图。
Q:贴图颜色比 Blender 深/浅?
A:确保颜色空间设置一致:BaseColor → sRGB,其他纹理(normal/metalness/roughness)→ Linear。
Q:热区 DOM 抖动或不准?
A:使用 CSS2DRenderer,或在更新渲染循环内频繁计算热点屏幕坐标(见示例)。
Q:如何实现产品自定义材质(换颜色/纹理)?
A:把需要换色的材质替换为 MeshStandardMaterial,修改 material.color / material.map 并设置 material.needsUpdate = true。
10. 小结与最佳实践清单
- 使用 glb + Draco + KTX2/Basis 做生产部署(节省带宽与加载时间)。
- 使用 PMREMGenerator + HDRI 获得高质量 PBR 反射。
- 统一模型尺度 / 居中(centerAndScaleToUnit)保证视角一致。
- 组件化:把 Three 初始化/清理/事件放在 Vue 生命周期钩子中,暴露 props & events。
- 强烈注意 资源释放(disposeGeometry / disposeTexture / renderer.dispose)。
- 优化 Draw Calls:合并网格、重用材质、使用 InstancedMesh。
- 为交互(热区、拾取)提前在建模工具里标注命名对象,以便在代码中直接使用。
发表回复