大家好,我是鱼樱!!!

关注公众号【鱼樱AI实验室】持续分享更多前端和AI辅助前端编码新知识~~

写点笔记写点生活~写点经验。

在当前环境下,纯前端开发者可以通过技术深化、横向扩展、切入新兴领域以及产品化思维找到突破口。

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

基于Vue3+Three.js实现3D数字展厅

一、项目概述

3D数字展厅是一种虚拟展示空间,通过Web技术在浏览器中呈现,让用户可以沉浸式体验展览内容。本文档详细记录了基于Vue3和Three.js实现3D数字展厅的完整过程。

二、技术选型

2.1 核心技术栈
  • 前端框架:Vue 3 + TypeScript
  • 3D渲染引擎:Three.js
  • 构建工具:Vite
  • 状态管理:Vue 3 Composition API
2.2 技术选型理由
  1. Vue 3
  • 更小的打包体积,提高加载速度
  • Composition API提供更灵活的代码组织方式
  • 更好的TypeScript支持
  1. Three.js
  • WebGL的成熟封装,降低3D开发门槛
  • 丰富的插件生态系统
  • 活跃的社区和详尽的文档
  1. 其他考虑的选项
  • Babylon.js:功能更全面但学习曲线较陡
  • PlayCanvas:商业项目需付费

四、实现过程

4.1 环境搭建
  1. 创建Vue3项目
npm create vue@latest my-exhibition-hall
cd my-exhibition-hall
  • 1.
  • 2.
  1. 安装依赖
npm install three@latest
npm install @types/three --save-dev
  • 1.
  • 2.
4.2 实现Three.js核心功能

创建useThree.ts组合式函数,封装Three.js的核心功能:

import * as THREE from 'three';
import { ref } from 'vue';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 定义场景加载选项接口
interface SceneLoadOptions {
  onProgress?: (progress: number) => void;
  onLoad?: () => void;
}

export function useThree() {
  // Three.js核心对象
  let scene: THREE.Scene | null = null;
  let camera: THREE.PerspectiveCamera | null = null;
  let renderer: THREE.WebGLRenderer | null = null;
  let controls: OrbitControls | null = null;
  
  // 初始化Three.js场景
  const initThreeScene = (): boolean => {
    try {
      // 初始化场景
      scene = new THREE.Scene();
      scene.background = new THREE.Color(0xf0f0f0);
      
      // 初始化摄像机
      camera = new THREE.PerspectiveCamera(
        75,
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      );
      camera.position.set(0, 1.6, 5);
      
      console.log("Three.js scene initialized");
      return true;
    } catch (error) {
      console.error("Failed to initialize Three.js scene:", error);
      return false;
    }
  };
  
  // 渲染展厅
  const renderExhibitionHall = async (
    container: HTMLElement | null,
    options: SceneLoadOptions = {}
  ): Promise<boolean> => {
    if (!container) {
      console.error("Container element is required");
      return false;
    }
    
    try {
      // 确保场景已初始化
      if (!scene || !camera) {
        const initialized = initThreeScene();
        if (!initialized) return false;
      }
      
      // 创建渲染器
      renderer = new THREE.WebGLRenderer({ antialias: true });
      renderer.setSize(container.clientWidth, container.clientHeight);
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      renderer.shadowMap.enabled = true;
      container.innerHTML = "";
      container.appendChild(renderer.domElement);
      
      // 设置控制器
      if (camera && renderer) {
        controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.05;
      }
      
      // 创建基础场景
      createBasicScene();
      
      // 开始渲染循环
      startRenderLoop();
      
      // 添加窗口大小调整监听
      window.addEventListener("resize", handleResize);
      
      options.onLoad?.();
      return true;
    } catch (error) {
      console.error("Failed to render exhibition hall:", error);
      return false;
    }
  };
  
  // 其他方法...
  
  return {
    initThreeScene,
    renderExhibitionHall,
    // 其他导出的方法...
  };
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
4.3 展厅场景创建

useThree.ts中添加创建展厅场景的方法:

// 创建基础场景
const createBasicScene = () => {
  if (!scene) return;

  // 添加地板
  const floorGeometry = new THREE.PlaneGeometry(50, 50);
  const floorMaterial = new THREE.MeshStandardMaterial({
    color: 0xeeeeee,
    roughness: 0.8,
  });
  const floor = new THREE.Mesh(floorGeometry, floorMaterial);
  floor.rotation.x = -Math.PI / 2;
  floor.receiveShadow = true;
  scene.add(floor);

  // 添加墙壁
  const wallMaterial = new THREE.MeshStandardMaterial({
    color: 0xffffff,
    roughness: 0.9,
  });

  // 后墙
  const backWall = new THREE.Mesh(
    new THREE.PlaneGeometry(50, 15),
    wallMaterial
  );
  backWall.position.z = -25;
  backWall.position.y = 7.5;
  scene.add(backWall);

  // 侧墙
  const leftWall = new THREE.Mesh(
    new THREE.PlaneGeometry(50, 15),
    wallMaterial
  );
  leftWall.position.x = -25;
  leftWall.position.y = 7.5;
  leftWall.rotation.y = Math.PI / 2;
  scene.add(leftWall);

  // 添加灯光
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
  scene.add(ambientLight);

  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
  directionalLight.position.set(5, 10, 7);
  directionalLight.castShadow = true;
  scene.add(directionalLight);

  // 添加展品
  addExhibits();
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
4.4 添加展品

展品添加与管理功能:

// 添加展品
const addExhibits = () => {
  if (!scene) return;

  // 展品位置
  const exhibitPositions = [
    { x: -8, y: 1, z: -5 },
    { x: -4, y: 1, z: -5 },
    { x: 0, y: 1, z: -5 },
    { x: 4, y: 1, z: -5 },
    { x: 8, y: 1, z: -5 },
  ];

  // 创建展台
  exhibitPositions.forEach((position, index) => {
    // 展台
    const standGeometry = new THREE.BoxGeometry(3, 0.2, 2);
    const standMaterial = new THREE.MeshStandardMaterial({
      color: 0x333333,
      roughness: 0.2,
      metalness: 0.8,
    });
    const stand = new THREE.Mesh(standGeometry, standMaterial);
    stand.position.set(position.x, position.y - 0.5, position.z);
    stand.castShadow = true;
    stand.receiveShadow = true;
    scene?.add(stand);

    // 展品(用简单几何体表示)
    const geometries = [
      new THREE.SphereGeometry(0.7),
      new THREE.BoxGeometry(1, 1, 1),
      new THREE.ConeGeometry(0.6, 1.5, 32),
      new THREE.TorusGeometry(0.5, 0.2, 16, 100),
      new THREE.DodecahedronGeometry(0.7),
    ];

    const geometry = geometries[index % geometries.length];
    const material = new THREE.MeshStandardMaterial({
      color: 0x1a75ff,
      roughness: 0.4,
      metalness: 0.6,
    });
    const exhibit = new THREE.Mesh(geometry, material);
    exhibit.position.set(position.x, position.y + 0.5, position.z);
    exhibit.castShadow = true;
    
    // 添加旋转动画
    const speed = 0.005 + Math.random() * 0.005;
    exhibit.userData = { rotationSpeed: speed };
    
    // 给展品添加交互性
    makeExhibitInteractive(exhibit, `展品 ${index + 1}`);
    
    scene?.add(exhibit);
  });
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
4.5 添加交互功能

为展品添加交互功能:

// 交互状态
const hoveredExhibit = ref<THREE.Object3D | null>(null);
const selectedExhibit = ref<THREE.Object3D | null>(null);

// 使展品可交互
const makeExhibitInteractive = (object: THREE.Object3D, name: string) => {
  object.userData.name = name;
  interactiveObjects.push(object);
};

// 设置鼠标交互
const setupInteraction = () => {
  if (!renderer) return;
  
  const raycaster = new THREE.Raycaster();
  const mouse = new THREE.Vector2();
  
  // 鼠标移动事件
  renderer.domElement.addEventListener('mousemove', (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;
    
    if (camera && scene) {
      raycaster.setFromCamera(mouse, camera);
      const intersects = raycaster.intersectObjects(interactiveObjects);
      
      if (intersects.length > 0) {
        const object = intersects[0].object;
        hoveredExhibit.value = object;
        document.body.style.cursor = 'pointer';
      } else {
        hoveredExhibit.value = null;
        document.body.style.cursor = 'auto';
      }
    }
  });
  
  // 点击事件
  renderer.domElement.addEventListener('click', () => {
    if (hoveredExhibit.value) {
      selectedExhibit.value = hoveredExhibit.value;
      // 显示展品详情
      showExhibitDetails(selectedExhibit.value);
    }
  });
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
4.6 展品详情展示

创建展品详情组件ExhibitDetail.vue

<template>
  <div v-if="exhibit" class="exhibit-detail">
    <h2>{
         
         { exhibit.userData.name }}</h2>
    <p class="description">{
         
         { exhibit.userData.description || '暂无描述' }}</p>
    <div class="actions">
      <button @click="closeDetail">关闭</button>
      <button @click="showMore">了解更多</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import * as THREE from 'three';

const props = defineProps<{
  exhibit: THREE.Object3D | null
}>();

const emit = defineEmits(['close', 'more']);

const closeDetail = () => {
  emit('close');
};

const showMore = () => {
  emit('more', props.exhibit);
};
</script>

<style scoped>
.exhibit-detail {
  position: absolute;
  bottom: 2rem;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(255, 255, 255, 0.9);
  padding: 1.5rem;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  max-width: 400px;
  z-index: 100;
}

/* 其他样式 */
</style>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
4.7 场景导航与控制

创建导航控制组件NavigationControls.vue

<template>
  <div class="navigation-controls">
    <div class="controls-group">
      <button @click="moveCamera('up')" title="向上看">
        <span class="icon">↑</span>
      </button>
      <button @click="moveCamera('down')" title="向下看">
        <span class="icon">↓</span>
      </button>
      <button @click="moveCamera('left')" title="向左看">
        <span class="icon">←</span>
      </button>
      <button @click="moveCamera('right')" title="向右看">
        <span class="icon">→</span>
      </button>
    </div>
    <div class="zoom-controls">
      <button @click="zoom('in')" title="放大">
        <span class="icon">+</span>
      </button>
      <button @click="zoom('out')" title="缩小">
        <span class="icon">-</span>
      </button>
    </div>
    <button class="reset-button" @click="resetView()" title="重置视图">
      <span class="icon">⟲</span>
    </button>
  </div>
</template>

<script setup lang="ts">
import { defineProps } from 'vue';

const props = defineProps<{
  controls: any
}>();

const moveCamera = (direction: 'up' | 'down' | 'left' | 'right') => {
  if (!props.controls) return;
  
  switch (direction) {
    case 'up':
      props.controls.rotateX(-0.1);
      break;
    case 'down':
      props.controls.rotateX(0.1);
      break;
    case 'left':
      props.controls.rotateY(-0.1);
      break;
    case 'right':
      props.controls.rotateY(0.1);
      break;
  }
};

const zoom = (type: 'in' | 'out') => {
  if (!props.controls) return;
  
  if (type === 'in') {
    props.controls.dollyIn(1.1);
  } else {
    props.controls.dollyOut(1.1);
  }
  
  props.controls.update();
};

const resetView = () => {
  if (!props.controls) return;
  
  props.controls.reset();
};
</script>

<style scoped>
/* 样式代码 */
</style>
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.