{T}

产品展示案例

概述

本章将介绍如何使用 Three.js 创建一个完整的 3D 产品展示应用,涵盖模型加载、材质渲染、交互控制、动画效果等核心技术。该方案适用于电商产品展示、工业产品演示、汽车展厅等场景。

案例目标

创建一个现代化的产品展示平台,具备以下特性:

  • 高质量产品渲染:真实感的材质与光照,支持 PBR 材质
  • 360° 自由旋转:用户可自由查看产品各个角度
  • 细节放大功能:支持局部放大查看,双击聚焦
  • 材质切换:动态切换产品颜色和材质,实时预览
  • 热点标注:在产品上添加交互式热点,展示产品细节
  • 动画演示:产品的功能动画展示,支持 GLTF 内置动画

系统架构

code
┌─────────────────────────────────────────────────────────────┐
│                        用户界面层 (UI)                        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │ 材质选择器 │ │ 动画控制  │ │ 热点面板  │ │ 加载进度  │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                       应用层 (Application)                    │
│  ┌─────────────────────────────────────────────────────┐   │
│  │              ProductShowcase (主控制器)               │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                       核心模块层 (Core)                       │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │SceneManager│ │ProductLoader│ │MaterialManager│ │Interaction│      │
│  │  场景管理  │ │  模型加载  │ │  材质管理  │ │  交互控制  │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                     渲染引擎层 (Three.js)                     │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │  Scene   │ │  Camera  │ │ Renderer │ │ Controls │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘

项目结构

code
product-showcase/
├── index.html              # 主页面
├── css/
│   └── style.css          # 样式文件
├── js/
│   ├── main.js            # 主入口
│   ├── SceneManager.js    # 场景管理
│   ├── ProductLoader.js   # 产品加载器
│   ├── MaterialManager.js # 材质管理
│   ├── Interaction.js     # 交互控制
│   └── Animation.js       # 动画系统
├── models/
│   └── product.glb        # 产品模型
└── textures/
    └── ...                # 纹理资源

核心代码实现

1. 场景管理器(SceneManager.js)

javascript
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

export class SceneManager {
  constructor(container) {
    this.container = container;
    this.scene = null;
    this.camera = null;
    this.renderer = null;
    this.controls = null;
    this.clock = new THREE.Clock();
    
    this.init();
  }
  
  init() {
    // 创建场景
    this.scene = new THREE.Scene();
    this.scene.background = new THREE.Color(0xf5f5f5);
    
    // 创建相机
    const aspect = this.container.clientWidth / this.container.clientHeight;
    this.camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 1000);
    this.camera.position.set(0, 0, 5);
    
    // 创建渲染器
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: true
    });
    this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.renderer.outputColorSpace = THREE.SRGBColorSpace;
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.0;
    
    this.container.appendChild(this.renderer.domElement);
    
    // 添加控制器
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.05;
    this.controls.minDistance = 2;
    this.controls.maxDistance = 10;
    this.controls.maxPolarAngle = Math.PI * 0.9;
    
    // 响应窗口大小变化
    window.addEventListener('resize', this.onWindowResize.bind(this));
    
    // 初始化光照
    this.setupLights();
    
    // 初始化环境
    this.setupEnvironment();
  }
  
  setupLights() {
    // 主光源 - 模拟主灯
    const mainLight = new THREE.DirectionalLight(0xffffff, 1.0);
    mainLight.position.set(5, 5, 5);
    mainLight.castShadow = true;
    mainLight.shadow.mapSize.width = 2048;
    mainLight.shadow.mapSize.height = 2048;
    mainLight.shadow.camera.near = 0.1;
    mainLight.shadow.camera.far = 50;
    mainLight.shadow.camera.left = -10;
    mainLight.shadow.camera.right = 10;
    mainLight.shadow.camera.top = 10;
    mainLight.shadow.camera.bottom = -10;
    this.scene.add(mainLight);
    
    // 补光 - 填充阴影区域
    const fillLight = new THREE.DirectionalLight(0xffffff, 0.5);
    fillLight.position.set(-5, 3, -5);
    this.scene.add(fillLight);
    
    // 轮廓光 - 勾勒产品边缘
    const rimLight = new THREE.DirectionalLight(0xffffff, 0.3);
    rimLight.position.set(0, -5, -5);
    this.scene.add(rimLight);
    
    // 环境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
    this.scene.add(ambientLight);
  }
  
  setupEnvironment() {
    // 加载环境贴图
    const pmremGenerator = new THREE.PMREMGenerator(this.renderer);
    pmremGenerator.compileEquirectangularShader();
    
    // 创建简单的环境贴图(可替换为 HDR 贴图)
    const envScene = new THREE.Scene();
    envScene.background = new THREE.Color(0xffffff);
    
    const envTexture = pmremGenerator.fromScene(envScene).texture;
    this.scene.environment = envTexture;
    
    pmremGenerator.dispose();
  }
  
  onWindowResize() {
    const width = this.container.clientWidth;
    const height = this.container.clientHeight;
    
    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();
    
    this.renderer.setSize(width, height);
  }
  
  animate() {
    requestAnimationFrame(this.animate.bind(this));
    
    const delta = this.clock.getDelta();
    
    this.controls.update();
    this.renderer.render(this.scene, this.camera);
  }
  
  add(object) {
    this.scene.add(object);
  }
  
  remove(object) {
    this.scene.remove(object);
  }
}

2. 产品加载器(ProductLoader.js)

javascript
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

export class ProductLoader {
  constructor(sceneManager) {
    this.sceneManager = sceneManager;
    this.loader = new GLTFLoader();
    
    // 配置 DRACO 解码器(用于压缩模型)
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath('/draco/');
    this.loader.setDRACOLoader(dracoLoader);
    
    this.product = null;
    this.mixer = null;
    this.animations = [];
    this.loadingManager = new THREE.LoadingManager();
    
    this.setupLoadingManager();
  }
  
  setupLoadingManager() {
    this.loadingManager.onStart = (url, itemsLoaded, itemsTotal) => {
      console.log(`开始加载: ${url}`);
      this.onLoadStart?.();
    };
    
    this.loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
      const progress = itemsLoaded / itemsTotal;
      this.onLoadProgress?.(progress);
    };
    
    this.loadingManager.onLoad = () => {
      console.log('加载完成');
      this.onLoadComplete?.();
    };
    
    this.loadingManager.onError = (url) => {
      console.error(`加载错误: ${url}`);
      this.onLoadError?.(url);
    };
    
    this.loader.manager = this.loadingManager;
  }
  
  async load(modelPath) {
    return new Promise((resolve, reject) => {
      this.loader.load(
        modelPath,
        (gltf) => {
          this.product = gltf.scene;
          this.animations = gltf.animations;
          
          // 配置模型
          this.configureModel();
          
          // 创建动画混合器
          if (this.animations.length > 0) {
            this.mixer = new THREE.AnimationMixer(this.product);
          }
          
          // 添加到场景
          this.sceneManager.add(this.product);
          
          resolve(this.product);
        },
        (progress) => {
          // 加载进度已在 LoadingManager 中处理
        },
        (error) => {
          reject(error);
        }
      );
    });
  }
  
  configureModel() {
    // 设置模型阴影
    this.product.traverse((child) => {
      if (child.isMesh) {
        child.castShadow = true;
        child.receiveShadow = true;
        
        // 保持原始材质引用
        child.userData.originalMaterial = child.material.clone();
      }
    });
    
    // 自动调整模型大小和位置
    this.normalizeModel();
  }
  
  normalizeModel() {
    // 计算模型边界
    const box = new THREE.Box3().setFromObject(this.product);
    const size = box.getSize(new THREE.Vector3());
    const center = box.getCenter(new THREE.Vector3());
    
    // 缩放到合适大小
    const maxDim = Math.max(size.x, size.y, size.z);
    const scale = 2 / maxDim;
    this.product.scale.multiplyScalar(scale);
    
    // 居中模型
    this.product.position.sub(center.multiplyScalar(scale));
  }
  
  playAnimation(index) {
    if (this.mixer && this.animations[index]) {
      const action = this.mixer.clipAction(this.animations[index]);
      action.reset().play();
    }
  }
  
  stopAnimation(index) {
    if (this.mixer && this.animations[index]) {
      const action = this.mixer.clipAction(this.animations[index]);
      action.stop();
    }
  }
  
  update(delta) {
    if (this.mixer) {
      this.mixer.update(delta);
    }
  }
  
  dispose() {
    if (this.product) {
      this.product.traverse((child) => {
        if (child.isMesh) {
          child.geometry.dispose();
          if (Array.isArray(child.material)) {
            child.material.forEach(mat => mat.dispose());
          } else {
            child.material.dispose();
          }
        }
      });
      
      this.sceneManager.remove(this.product);
    }
    
    if (this.mixer) {
      this.mixer.stopAllAction();
    }
  }
}

3. 材质管理器(MaterialManager.js)

javascript
import * as THREE from 'three';

export class MaterialManager {
  constructor(productLoader) {
    this.productLoader = productLoader;
    this.materials = new Map();
    this.currentMaterial = null;
    
    // 预定义材质配置
    this.materialPresets = {
      gold: {
        color: 0xffd700,
        metalness: 1.0,
        roughness: 0.3
      },
      silver: {
        color: 0xc0c0c0,
        metalness: 1.0,
        roughness: 0.2
      },
      black: {
        color: 0x1a1a1a,
        metalness: 0.5,
        roughness: 0.5
      },
      white: {
        color: 0xffffff,
        metalness: 0.0,
        roughness: 0.8
      },
      red: {
        color: 0xff0000,
        metalness: 0.3,
        roughness: 0.4
      }
    };
  }
  
  // 创建材质
  createMaterial(presetName, options = {}) {
    const preset = this.materialPresets[presetName] || this.materialPresets.white;
    
    const material = new THREE.MeshStandardMaterial({
      color: options.color || preset.color,
      metalness: options.metalness ?? preset.metalness,
      roughness: options.roughness ?? preset.roughness,
      envMapIntensity: 1.0
    });
    
    this.materials.set(presetName, material);
    return material;
  }
  
  // 应用材质到产品
  applyMaterial(materialName) {
    if (!this.productLoader.product) return;
    
    const material = this.materials.get(materialName) || 
                     this.createMaterial(materialName);
    
    this.productLoader.product.traverse((child) => {
      if (child.isMesh) {
        // 创建材质实例(避免共享材质)
        child.material = material.clone();
      }
    });
    
    this.currentMaterial = materialName;
    this.onMaterialChange?.(materialName);
  }
  
  // 应用自定义材质
  applyCustomMaterial(config) {
    const material = new THREE.MeshStandardMaterial({
      color: config.color,
      metalness: config.metalness,
      roughness: config.roughness,
      envMapIntensity: config.envMapIntensity || 1.0
    });
    
    this.productLoader.product.traverse((child) => {
      if (child.isMesh) {
        child.material = material.clone();
      }
    });
  }
  
  // 恢复原始材质
  restoreOriginalMaterial() {
    if (!this.productLoader.product) return;
    
    this.productLoader.product.traverse((child) => {
      if (child.isMesh && child.userData.originalMaterial) {
        child.material = child.userData.originalMaterial.clone();
      }
    });
    
    this.currentMaterial = null;
    this.onMaterialChange?.('original');
  }
  
  // 获取当前材质信息
  getCurrentMaterialInfo() {
    if (!this.currentMaterial) {
      return { name: 'original', displayName: '原始材质' };
    }
    
    const preset = this.materialPresets[this.currentMaterial];
    return {
      name: this.currentMaterial,
      displayName: this.getMaterialDisplayName(this.currentMaterial),
      config: preset
    };
  }
  
  getMaterialDisplayName(name) {
    const displayNames = {
      gold: '金色',
      silver: '银色',
      black: '黑色',
      white: '白色',
      red: '红色'
    };
    return displayNames[name] || name;
  }
  
  dispose() {
    this.materials.forEach(material => material.dispose());
    this.materials.clear();
  }
}

4. 交互控制器(Interaction.js)

javascript
import * as THREE from 'three';

export class Interaction {
  constructor(sceneManager) {
    this.sceneManager = sceneManager;
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.selectedObject = null;
    this.hoveredObject = null;
    this.hotspots = [];
    
    this.bindEvents();
  }
  
  bindEvents() {
    const canvas = this.sceneManager.renderer.domElement;
    
    canvas.addEventListener('click', this.onClick.bind(this));
    canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
    canvas.addEventListener('touchstart', this.onTouchStart.bind(this));
  }
  
  // 添加热点
  addHotspot(position, data) {
    // 创建热点几何体
    const geometry = new THREE.SphereGeometry(0.05, 16, 16);
    const material = new THREE.MeshBasicMaterial({
      color: 0xffffff,
      transparent: true,
      opacity: 0.8
    });
    
    const hotspot = new THREE.Mesh(geometry, material);
    hotspot.position.copy(position);
    hotspot.userData = {
      type: 'hotspot',
      data: data
    };
    
    this.hotspots.push(hotspot);
    this.sceneManager.add(hotspot);
    
    return hotspot;
  }
  
  // 移除热点
  removeHotspot(hotspot) {
    const index = this.hotspots.indexOf(hotspot);
    if (index > -1) {
      this.hotspots.splice(index, 1);
      this.sceneManager.remove(hotspot);
      hotspot.geometry.dispose();
      hotspot.material.dispose();
    }
  }
  
  onClick(event) {
    this.updateMouse(event);
    
    const intersects = this.getIntersections();
    
    if (intersects.length > 0) {
      const object = intersects[0].object;
      
      // 检查是否点击了热点
      if (object.userData.type === 'hotspot') {
        this.onHotspotClick?.(object.userData.data);
      } else {
        this.onProductClick?.(object, intersects[0].point);
      }
    } else {
      this.onBackgroundClick?.();
    }
  }
  
  onMouseMove(event) {
    this.updateMouse(event);
    
    const intersects = this.getIntersections();
    
    if (intersects.length > 0) {
      const object = intersects[0].object;
      
      if (this.hoveredObject !== object) {
        // 鼠标离开上一个对象
        if (this.hoveredObject) {
          this.onObjectLeave?.(this.hoveredObject);
        }
        
        // 鼠标进入新对象
        this.hoveredObject = object;
        this.onObjectEnter?.(object);
      }
      
      // 更新鼠标样式
      this.sceneManager.renderer.domElement.style.cursor = 'pointer';
    } else {
      if (this.hoveredObject) {
        this.onObjectLeave?.(this.hoveredObject);
        this.hoveredObject = null;
      }
      
      this.sceneManager.renderer.domElement.style.cursor = 'default';
    }
  }
  
  onTouchStart(event) {
    if (event.touches.length === 1) {
      const touch = event.touches[0];
      this.updateMouse(touch);
      
      const intersects = this.getIntersections();
      
      if (intersects.length > 0) {
        event.preventDefault();
        const object = intersects[0].object;
        
        if (object.userData.type === 'hotspot') {
          this.onHotspotClick?.(object.userData.data);
        }
      }
    }
  }
  
  updateMouse(event) {
    const rect = this.sceneManager.renderer.domElement.getBoundingClientRect();
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  }
  
  getIntersections() {
    this.raycaster.setFromCamera(this.mouse, this.sceneManager.camera);
    
    const objects = [...this.hotspots];
    if (this.sceneManager.scene.children.length > 0) {
      objects.push(...this.sceneManager.scene.children.filter(
        obj => obj.isMesh && obj.userData.type !== 'hotspot'
      ));
    }
    
    return this.raycaster.intersectObjects(objects, true);
  }
  
  // 聚焦到特定对象
  focusOnObject(object, duration = 1000) {
    const box = new THREE.Box3().setFromObject(object);
    const center = box.getCenter(new THREE.Vector3());
    const size = box.getSize(new THREE.Vector3());
    
    const maxDim = Math.max(size.x, size.y, size.z);
    const distance = maxDim * 2;
    
    const targetPosition = new THREE.Vector3(
      center.x + distance,
      center.y + distance * 0.5,
      center.z + distance
    );
    
    this.animateCameraTo(targetPosition, center, duration);
  }
  
  // 相机动画
  animateCameraTo(targetPosition, targetLookAt, duration) {
    const startPosition = this.sceneManager.camera.position.clone();
    const startTarget = this.sceneManager.controls.target.clone();
    const startTime = Date.now();
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      // 使用缓动函数
      const eased = this.easeInOutCubic(progress);
      
      // 插值位置
      this.sceneManager.camera.position.lerpVectors(
        startPosition,
        targetPosition,
        eased
      );
      
      // 插值目标点
      this.sceneManager.controls.target.lerpVectors(
        startTarget,
        targetLookAt,
        eased
      );
      
      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };
    
    animate();
  }
  
  easeInOutCubic(t) {
    return t < 0.5
      ? 4 * t * t * t
      : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }
  
  dispose() {
    const canvas = this.sceneManager.renderer.domElement;
    canvas.removeEventListener('click', this.onClick);
    canvas.removeEventListener('mousemove', this.onMouseMove);
    canvas.removeEventListener('touchstart', this.onTouchStart);
    
    this.hotspots.forEach(hotspot => {
      hotspot.geometry.dispose();
      hotspot.material.dispose();
    });
    this.hotspots = [];
  }
}

5. 主入口文件(main.js)

javascript
import { SceneManager } from './SceneManager.js';
import { ProductLoader } from './ProductLoader.js';
import { MaterialManager } from './MaterialManager.js';
import { Interaction } from './Interaction.js';

class ProductShowcase {
  constructor() {
    this.container = document.getElementById('app');
    this.clock = new THREE.Clock();
    
    this.init();
  }
  
  async init() {
    // 创建场景管理器
    this.sceneManager = new SceneManager(this.container);
    
    // 创建产品加载器
    this.productLoader = new ProductLoader(this.sceneManager);
    
    // 创建材质管理器
    this.materialManager = new MaterialManager(this.productLoader);
    
    // 创建交互控制器
    this.interaction = new Interaction(this.sceneManager);
    
    // 设置回调
    this.setupCallbacks();
    
    // 加载产品
    await this.loadProduct();
    
    // 初始化UI
    this.initUI();
    
    // 开始渲染循环
    this.animate();
  }
  
  setupCallbacks() {
    // 加载进度
    this.productLoader.onLoadStart = () => {
      this.showLoading();
    };
    
    this.productLoader.onLoadProgress = (progress) => {
      this.updateLoadingProgress(progress);
    };
    
    this.productLoader.onLoadComplete = () => {
      this.hideLoading();
    };
    
    // 材质变化
    this.materialManager.onMaterialChange = (materialName) => {
      this.updateMaterialUI(materialName);
    };
    
    // 热点点击
    this.interaction.onHotspotClick = (data) => {
      this.showHotspotInfo(data);
    };
    
    // 产品点击
    this.interaction.onProductClick = (object, point) => {
      console.log('产品被点击:', object.name, point);
    };
  }
  
  async loadProduct() {
    try {
      await this.productLoader.load('/models/product.glb');
      
      // 添加热点示例
      this.addDemoHotspots();
      
      // 如果有动画,播放第一个
      if (this.productLoader.animations.length > 0) {
        this.productLoader.playAnimation(0);
      }
    } catch (error) {
      console.error('产品加载失败:', error);
    }
  }
  
  addDemoHotspots() {
    // 在产品上添加热点示例
    this.interaction.addHotspot(
      new THREE.Vector3(0, 0.5, 0),
      {
        title: '产品细节',
        description: '这里是产品的详细说明',
        image: '/images/detail.jpg'
      }
    );
    
    this.interaction.addHotspot(
      new THREE.Vector3(0.5, 0, 0),
      {
        title: '材质特点',
        description: '优质材料制成'
      }
    );
  }
  
  initUI() {
    // 创建材质选择器
    this.createMaterialSelector();
    
    // 创建动画控制按钮
    this.createAnimationControls();
    
    // 创建热点信息面板
    this.createHotspotPanel();
  }
  
  createMaterialSelector() {
    const container = document.getElementById('material-selector');
    if (!container) return;
    
    const materials = ['original', 'gold', 'silver', 'black', 'white', 'red'];
    
    materials.forEach(name => {
      const button = document.createElement('button');
      button.textContent = this.materialManager.getMaterialDisplayName(name);
      button.dataset.material = name;
      
      button.addEventListener('click', () => {
        if (name === 'original') {
          this.materialManager.restoreOriginalMaterial();
        } else {
          this.materialManager.applyMaterial(name);
        }
      });
      
      container.appendChild(button);
    });
  }
  
  createAnimationControls() {
    const container = document.getElementById('animation-controls');
    if (!container || this.productLoader.animations.length === 0) return;
    
    this.productLoader.animations.forEach((clip, index) => {
      const button = document.createElement('button');
      button.textContent = clip.name || `动画 ${index + 1}`;
      
      button.addEventListener('click', () => {
        this.productLoader.playAnimation(index);
      });
      
      container.appendChild(button);
    });
  }
  
  createHotspotPanel() {
    this.hotspotPanel = document.getElementById('hotspot-panel');
    if (!this.hotspotPanel) return;
    
    this.hotspotPanel.classList.add('hidden');
  }
  
  showHotspotInfo(data) {
    if (!this.hotspotPanel) return;
    
    this.hotspotPanel.innerHTML = `
      <h3>${data.title}</h3>
      <p>${data.description}</p>
      ${data.image ? `<img src="${data.image}" alt="${data.title}">` : ''}
      <button class="close-button">×</button>
    `;
    
    this.hotspotPanel.classList.remove('hidden');
    
    // 关闭按钮
    const closeButton = this.hotspotPanel.querySelector('.close-button');
    closeButton.addEventListener('click', () => {
      this.hideHotspotInfo();
    });
  }
  
  hideHotspotInfo() {
    if (this.hotspotPanel) {
      this.hotspotPanel.classList.add('hidden');
    }
  }
  
  showLoading() {
    const loader = document.getElementById('loader');
    if (loader) {
      loader.classList.remove('hidden');
    }
  }
  
  updateLoadingProgress(progress) {
    const progressBar = document.getElementById('progress-bar');
    if (progressBar) {
      progressBar.style.width = `${progress * 100}%`;
    }
  }
  
  hideLoading() {
    const loader = document.getElementById('loader');
    if (loader) {
      loader.classList.add('hidden');
    }
  }
  
  updateMaterialUI(materialName) {
    const buttons = document.querySelectorAll('#material-selector button');
    buttons.forEach(button => {
      button.classList.toggle('active', button.dataset.material === materialName);
    });
  }
  
  animate() {
    requestAnimationFrame(this.animate.bind(this));
    
    const delta = this.clock.getDelta();
    
    // 更新产品动画
    this.productLoader.update(delta);
    
    // 渲染场景
    this.sceneManager.animate();
  }
  
  dispose() {
    this.productLoader.dispose();
    this.materialManager.dispose();
    this.interaction.dispose();
  }
}

// 启动应用
const app = new ProductShowcase();

HTML 页面结构

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>3D 产品展示</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div id="app"></div>
  
  <!-- 加载器 -->
  <div id="loader">
    <div class="progress-container">
      <div id="progress-bar"></div>
    </div>
    <p>加载中...</p>
  </div>
  
  <!-- 控制面板 -->
  <div id="controls">
    <div class="control-group">
      <h3>材质选择</h3>
      <div id="material-selector"></div>
    </div>
    
    <div class="control-group">
      <h3>动画控制</h3>
      <div id="animation-controls"></div>
    </div>
  </div>
  
  <!-- 热点信息面板 -->
  <div id="hotspot-panel"></div>
  
  <script type="module" src="js/main.js"></script>
</body>
</html>

样式文件(style.css)

css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  overflow: hidden;
  background: #f5f5f5;
}

#app {
  width: 100vw;
  height: 100vh;
}

#app canvas {
  display: block;
}

/* 加载器 */
#loader {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  z-index: 100;
}

#loader.hidden {
  display: none;
}

.progress-container {
  width: 200px;
  height: 4px;
  background: #ddd;
  border-radius: 2px;
  overflow: hidden;
}

#progress-bar {
  width: 0;
  height: 100%;
  background: linear-gradient(90deg, #4a90e2, #67b26f);
  transition: width 0.3s ease;
}

#loader p {
  margin-top: 10px;
  color: #666;
}

/* 控制面板 */
#controls {
  position: fixed;
  top: 20px;
  right: 20px;
  background: white;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  max-width: 280px;
}

.control-group {
  margin-bottom: 20px;
}

.control-group:last-child {
  margin-bottom: 0;
}

.control-group h3 {
  font-size: 14px;
  color: #333;
  margin-bottom: 12px;
}

#material-selector,
#animation-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

button {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 6px;
  cursor: pointer;
  font-size: 13px;
  transition: all 0.2s ease;
}

button:hover {
  border-color: #4a90e2;
  color: #4a90e2;
}

button.active {
  background: #4a90e2;
  border-color: #4a90e2;
  color: white;
}

/* 热点信息面板 */
#hotspot-panel {
  position: fixed;
  bottom: 20px;
  left: 20px;
  background: white;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  max-width: 320px;
  transition: opacity 0.3s ease, transform 0.3s ease;
}

#hotspot-panel.hidden {
  opacity: 0;
  transform: translateY(20px);
  pointer-events: none;
}

#hotspot-panel h3 {
  font-size: 18px;
  color: #333;
  margin-bottom: 10px;
}

#hotspot-panel p {
  font-size: 14px;
  color: #666;
  line-height: 1.6;
  margin-bottom: 15px;
}

#hotspot-panel img {
  width: 100%;
  border-radius: 8px;
  margin-bottom: 15px;
}

.close-button {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 24px;
  height: 24px;
  padding: 0;
  border: none;
  background: #f0f0f0;
  border-radius: 50%;
  font-size: 18px;
  line-height: 24px;
  color: #999;
}

.close-button:hover {
  background: #e0e0e0;
  color: #666;
}

/* 移动端适配 */
@media (max-width: 768px) {
  #controls {
    top: auto;
    bottom: 80px;
    right: 10px;
    left: 10px;
    max-width: none;
    display: flex;
    gap: 20px;
  }
  
  .control-group {
    flex: 1;
    margin-bottom: 0;
  }
  
  #hotspot-panel {
    left: 10px;
    right: 10px;
    max-width: none;
  }
}

最佳实践

1. 模型优化

javascript
// 使用 DRACO 压缩模型
// 在 Blender 中导出时勾选 DRACO 压缩选项

// 或使用 gltf-pipeline 工具
// gltf-pipeline -i model.gltf -o model.glb -d

// 加载时使用 DRACOLoader
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
gltfLoader.setDRACOLoader(dracoLoader);

2. 性能优化

javascript
// 使用 LOD(细节层次)
const lod = new THREE.LOD();
lod.addLevel(highDetailMesh, 0);
lod.addLevel(mediumDetailMesh, 5);
lod.addLevel(lowDetailMesh, 10);

// 使用 InstancedMesh 渲染重复物体
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshStandardMaterial();
const mesh = new THREE.InstancedMesh(geometry, material, count);

// 减少阴影计算
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap; // 比 PCFSoftShadowMap 快

3. 响应式设计

javascript
// 根据设备性能调整渲染质量
function adjustQuality() {
  const isMobile = /Android|iPhone/i.test(navigator.userAgent);
  
  if (isMobile) {
    renderer.setPixelRatio(1);
    renderer.shadowMap.enabled = false;
    // 简化材质
  } else {
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.shadowMap.enabled = true;
  }
}

4. 无障碍访问

html
<!-- 添加键盘控制 -->
<div id="app" role="img" aria-label="3D产品展示">
  <p>使用鼠标拖动旋转产品,滚轮缩放</p>
</div>

<!-- 添加屏幕阅读器描述 -->
<div class="sr-only" aria-live="polite" id="sr-announcer"></div>

API 接口说明

SceneManager API

方法参数返回值说明
constructor(container)container: HTMLElementSceneManager创建场景管理器实例
add(object)object: THREE.Object3Dvoid添加对象到场景
remove(object)object: THREE.Object3Dvoid从场景移除对象
animate()-void启动渲染循环
dispose()-void销毁场景资源

ProductLoader API

方法参数返回值说明
load(modelPath)modelPath: stringPromise<THREE.Group>加载 GLTF/GLB 模型
playAnimation(index)index: numbervoid播放指定索引动画
stopAnimation(index)index: numbervoid停止指定索引动画
update(delta)delta: numbervoid更新动画状态
dispose()-void释放模型资源

MaterialManager API

方法参数返回值说明
createMaterial(presetName, options)presetName: string, options?: objectTHREE.MeshStandardMaterial创建预设材质
applyMaterial(materialName)materialName: stringvoid应用材质到产品
applyCustomMaterial(config)config: MaterialConfigvoid应用自定义材质
restoreOriginalMaterial()-void恢复原始材质

Interaction API

方法参数返回值说明
addHotspot(position, data)position: THREE.Vector3, data: HotspotDataTHREE.Mesh添加热点标注
removeHotspot(hotspot)hotspot: THREE.Meshvoid移除热点
focusOnObject(object, duration)object: THREE.Object3D, duration?: numbervoid聚焦到对象

配置参数详解

SceneManager 配置

javascript
const sceneConfig = {
  // 相机配置
  camera: {
    fov: 45,              // 视野角度
    near: 0.1,            // 近裁剪面
    far: 1000,            // 远裁剪面
    position: [0, 0, 5]   // 初始位置 [x, y, z]
  },
  
  // 渲染器配置
  renderer: {
    antialias: true,      // 抗锯齿
    alpha: true,          // 透明背景
    pixelRatio: 2,        // 像素比上限
    shadowMap: true,      // 启用阴影
    toneMapping: 'ACESFilmic',  // 色调映射
    exposure: 1.0         // 曝光度
  },
  
  // 控制器配置
  controls: {
    enableDamping: true,  // 启用阻尼
    dampingFactor: 0.05,  // 阻尼系数
    minDistance: 2,       // 最小缩放距离
    maxDistance: 10,      // 最大缩放距离
    maxPolarAngle: Math.PI * 0.9  // 最大极角
  }
};

材质预设配置

javascript
const materialPresets = {
  gold: {
    color: 0xffd700,      // 金色
    metalness: 1.0,       // 金属度
    roughness: 0.3        // 粗糙度
  },
  silver: {
    color: 0xc0c0c0,      // 银色
    metalness: 1.0,
    roughness: 0.2
  },
  plastic: {
    color: 0xffffff,
    metalness: 0.0,
    roughness: 0.4        // 塑料质感
  },
  matte: {
    color: 0x333333,
    metalness: 0.1,
    roughness: 0.9        // 哑光质感
  }
};

热点数据结构

javascript
const hotspotData = {
  title: '产品细节',          // 热点标题
  description: '详细说明...',  // 热点描述
  image: '/images/detail.jpg', // 可选图片
  link: 'https://...',         // 可选链接
  position: [x, y, z]          // 3D 位置
};

常见问题解答

Q1: 模型加载失败怎么办?

A: 检查以下几点:

  1. 确认模型文件路径正确,支持 .glb.gltf 格式
  2. 检查服务器是否正确配置 MIME 类型
  3. 查看浏览器控制台是否有 CORS 错误
  4. 确认模型文件未损坏
javascript
// 推荐的错误处理方式
productLoader.onLoadError = (url) => {
  console.error(`加载失败: ${url}`);
  // 显示友好的错误提示
  showErrorToast('模型加载失败,请刷新重试');
};

Q2: 如何优化模型加载速度?

A: 建议采取以下措施:

  1. 使用 DRACO 压缩:可减少 60-80% 文件体积
  2. 按需加载:先加载低精度模型,再加载高精度版本
  3. 使用 CDN:将模型资源部署到 CDN
  4. 添加加载进度:让用户了解加载状态
bash
# 使用 gltf-pipeline 压缩模型
gltf-pipeline -i model.gltf -o model.glb -d

Q3: 材质显示不真实怎么办?

A: 确保正确设置环境光照:

  1. 添加 HDR 环境贴图
  2. 调整 metalnessroughness 参数
  3. 使用 PMREMGenerator 预处理环境贴图
javascript
// 加载 HDR 环境贴图
const rgbeLoader = new RGBELoader();
rgbeLoader.load('/hdr/studio.hdr', (texture) => {
  const pmremGenerator = new THREE.PMREMGenerator(renderer);
  const envTexture = pmremGenerator.fromEquirectangular(texture).texture;
  scene.environment = envTexture;
  texture.dispose();
});

Q4: 移动端性能差如何优化?

A: 针对移动端的优化建议:

  1. 降低模型面数,使用 LOD 技术
  2. 减少实时光源数量
  3. 禁用或简化阴影效果
  4. 限制帧率到 30fps
javascript
// 移动端检测与优化
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
if (isMobile) {
  renderer.setPixelRatio(1);
  renderer.shadowMap.enabled = false;
  // 使用简化材质
}

Q5: 热点位置如何精确定位?

A: 推荐使用模型编辑器预设置:

  1. 在 Blender 中创建空对象作为热点位置
  2. 导出时保留这些辅助对象
  3. 在 Three.js 中读取位置信息
javascript
// 从模型中读取预设热点
model.traverse((child) => {
  if (child.userData.type === 'hotspot') {
    interaction.addHotspot(child.position, child.userData.info);
  }
});

相关链接