菜鸟-创作你的创作

一文详解如何用Three.js和Vue 3实现3D商品展示

一文详解:用 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. 功能点设计(需求一览)


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>

说明(快速)


4. 导出模型建议(Blender → GLB;最佳实践)


5. 性能优化要点(生产级)

  1. 压缩传输
    • Draco 压缩顶点(glTF + Draco)
    • Basis/KTX2 用于纹理(比 JPEG/PNG 更省带宽)
  2. 减 draw calls
    • 合并网格(相同材质合并)
    • 尽量复用材质与纹理
  3. 限制像素比
    • renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 或更保守
  4. 按需加载/延迟加载
    • 初始只加载主视图模型;细节部件/高分纹理在需要时懒加载
  5. GPU 友好
    • 使用 PBR 标准材质(MeshStandardMaterial),减少自定义 shader(除非必要)
    • 避免在每帧创建/销毁对象
  6. Batch / Instancing
    • 同类重复对象(配件、按钮)使用 InstancedMesh
  7. 避免控制流扁平化?(与 obfuscation 无关,此处略)

6. 交互增强(进阶功能)


7. 调试与开发技巧


8. 与 Vue 响应式结合(props 更新、销毁、事件)

示例(在 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. 小结与最佳实践清单

退出移动版