产品展示案例
概述
本章将介绍如何使用 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: HTMLElement | SceneManager | 创建场景管理器实例 |
add(object) | object: THREE.Object3D | void | 添加对象到场景 |
remove(object) | object: THREE.Object3D | void | 从场景移除对象 |
animate() | - | void | 启动渲染循环 |
dispose() | - | void | 销毁场景资源 |
ProductLoader API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
load(modelPath) | modelPath: string | Promise<THREE.Group> | 加载 GLTF/GLB 模型 |
playAnimation(index) | index: number | void | 播放指定索引动画 |
stopAnimation(index) | index: number | void | 停止指定索引动画 |
update(delta) | delta: number | void | 更新动画状态 |
dispose() | - | void | 释放模型资源 |
MaterialManager API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
createMaterial(presetName, options) | presetName: string, options?: object | THREE.MeshStandardMaterial | 创建预设材质 |
applyMaterial(materialName) | materialName: string | void | 应用材质到产品 |
applyCustomMaterial(config) | config: MaterialConfig | void | 应用自定义材质 |
restoreOriginalMaterial() | - | void | 恢复原始材质 |
Interaction API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
addHotspot(position, data) | position: THREE.Vector3, data: HotspotData | THREE.Mesh | 添加热点标注 |
removeHotspot(hotspot) | hotspot: THREE.Mesh | void | 移除热点 |
focusOnObject(object, duration) | object: THREE.Object3D, duration?: number | void | 聚焦到对象 |
配置参数详解
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: 检查以下几点:
- 确认模型文件路径正确,支持
.glb和.gltf格式 - 检查服务器是否正确配置 MIME 类型
- 查看浏览器控制台是否有 CORS 错误
- 确认模型文件未损坏
javascript
// 推荐的错误处理方式
productLoader.onLoadError = (url) => {
console.error(`加载失败: ${url}`);
// 显示友好的错误提示
showErrorToast('模型加载失败,请刷新重试');
};Q2: 如何优化模型加载速度?
A: 建议采取以下措施:
- 使用 DRACO 压缩:可减少 60-80% 文件体积
- 按需加载:先加载低精度模型,再加载高精度版本
- 使用 CDN:将模型资源部署到 CDN
- 添加加载进度:让用户了解加载状态
bash
# 使用 gltf-pipeline 压缩模型
gltf-pipeline -i model.gltf -o model.glb -dQ3: 材质显示不真实怎么办?
A: 确保正确设置环境光照:
- 添加 HDR 环境贴图
- 调整
metalness和roughness参数 - 使用
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: 针对移动端的优化建议:
- 降低模型面数,使用 LOD 技术
- 减少实时光源数量
- 禁用或简化阴影效果
- 限制帧率到 30fps
javascript
// 移动端检测与优化
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
if (isMobile) {
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = false;
// 使用简化材质
}Q5: 热点位置如何精确定位?
A: 推荐使用模型编辑器预设置:
- 在 Blender 中创建空对象作为热点位置
- 导出时保留这些辅助对象
- 在 Three.js 中读取位置信息
javascript
// 从模型中读取预设热点
model.traverse((child) => {
if (child.userData.type === 'hotspot') {
interaction.addHotspot(child.position, child.userData.info);
}
});