第一章:引言与项目概述

1.1 3D编辑器的市场需求与技术前景

随着数字孪生、元宇宙、工业仿真等领域的快速发展,Web 3D编辑器正成为前端开发的重要方向。与传统桌面3D软件相比,基于Web的3D编辑器具有以下优势:

  • 跨平台访问:无需安装,浏览器即可使用

  • 易于分享与协作:基于Web的天然协作特性

  • 云原生架构:数据可存储在云端,实现多端同步

  • 开发效率高:前端技术栈迭代快,开发周期短

1.2 技术栈选择:为何选择Vue3 + Three.js

Vue3的优势:

  • 组合式API提供更好的逻辑复用

  • 更好的TypeScript支持

  • 更小的打包体积和更好的性能

  • 响应式系统更加完善

Three.js的优势:

  • 成熟稳定的WebGL封装

  • 丰富的示例和社区支持

  • 活跃的更新维护

  • 功能全面,涵盖3D开发的各个方面

1.3 项目目标与功能规划

我们将开发一个功能完备的Web 3D编辑器,主要包括:

  1. 基础3D场景管理

  2. 模型导入与导出

  3. 场景编辑功能(选择、移动、旋转、缩放)

  4. 材质与纹理编辑

  5. 光照系统配置

  6. 动画编辑与时间线

  7. 场景序列化与反序列化

  8. 插件系统与扩展性

第二章:项目架构设计与初始化

2.1 项目初始化与配置

bash

# 创建Vue3项目
npm create vue@latest vue3-3d-editor
# 选择配置:TypeScript + Router + Pinia + ESLint

# 安装Three.js及相关依赖
npm install three
npm install @types/three
npm install drei @react-three/fiber # 可选,Three.js的React封装,Vue中可参考其思路
npm install lodash-es
npm install axios
npm install file-saver

2.2 项目目录结构设计

text

src/
├── assets/           # 静态资源
├── components/       # Vue组件
│   ├── 3d/          # 3D相关组件
│   ├── ui/          # UI组件
│   └── common/      # 通用组件
├── composables/      # 组合式函数
│   ├── useThree.ts  # Three.js核心逻辑
│   ├── useScene.ts  # 场景管理
│   ├── useEditor.ts # 编辑器状态
│   └── useTools.ts  # 工具函数
├── core/            # 核心模块
│   ├── EditorCore.ts # 编辑器核心类
│   ├── SceneManager.ts # 场景管理器
│   ├── ObjectManager.ts # 对象管理器
│   └── EventSystem.ts # 事件系统
├── plugins/         # 插件系统
├── stores/          # Pinia状态管理
├── types/           # TypeScript类型定义
├── utils/           # 工具函数
└── views/           # 页面视图

2.3 编辑器核心架构设计

typescript

// core/EditorCore.ts
export class EditorCore {
  private scene: THREE.Scene;
  private camera: THREE.Camera;
  private renderer: THREE.WebGLRenderer;
  private sceneManager: SceneManager;
  private objectManager: ObjectManager;
  private eventSystem: EventSystem;
  private state: EditorState;
  
  constructor(container: HTMLElement) {
    this.initThreeJS(container);
    this.initManagers();
    this.initEventSystem();
    this.initState();
  }
  
  private initThreeJS(container: HTMLElement): void {
    // 初始化Three.js核心组件
    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    
    this.renderer.setSize(container.clientWidth, container.clientHeight);
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    
    container.appendChild(this.renderer.domElement);
  }
}

第三章:Three.js基础与场景构建

3.1 Three.js核心概念详解

三大核心组件:

  1. 场景(Scene):3D对象的容器

  2. 相机(Camera):观察场景的视角

  3. 渲染器(Renderer):将3D场景渲染到2D画布

typescript

// composables/useThree.ts
import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

export function useThree(containerRef: Ref<HTMLElement>) {
  const scene = ref<THREE.Scene>();
  const camera = ref<THREE.PerspectiveCamera>();
  const renderer = ref<THREE.WebGLRenderer>();
  const controls = ref<OrbitControls>();
  const clock = new THREE.Clock();
  
  const init = () => {
    // 1. 创建场景
    scene.value = new THREE.Scene();
    scene.value.background = new THREE.Color(0x444444);
    
    // 2. 创建相机
    camera.value = new THREE.PerspectiveCamera(
      75,
      containerRef.value.clientWidth / containerRef.value.clientHeight,
      0.1,
      1000
    );
    camera.value.position.set(5, 5, 5);
    camera.value.lookAt(0, 0, 0);
    
    // 3. 创建渲染器
    renderer.value = new THREE.WebGLRenderer({
      antialias: true,
      alpha: true,
      preserveDrawingBuffer: true // 用于截图
    });
    
    renderer.value.setSize(
      containerRef.value.clientWidth,
      containerRef.value.clientHeight
    );
    
    renderer.value.shadowMap.enabled = true;
    renderer.value.shadowMap.type = THREE.PCFSoftShadowMap;
    
    containerRef.value.appendChild(renderer.value.domElement);
    
    // 4. 添加轨道控制器
    controls.value = new OrbitControls(
      camera.value,
      renderer.value.domElement
    );
    controls.value.enableDamping = true;
    controls.value.dampingFactor = 0.05;
    
    // 5. 添加光照
    addDefaultLights();
    
    // 6. 添加辅助工具
    addHelpers();
    
    // 7. 开始渲染循环
    animate();
  };
  
  const animate = () => {
    requestAnimationFrame(animate);
    
    const delta = clock.getDelta();
    
    if (controls.value) {
      controls.value.update();
    }
    
    // 更新场景中的动画
    updateAnimations(delta);
    
    if (renderer.value && scene.value && camera.value) {
      renderer.value.render(scene.value, camera.value);
    }
  };
  
  return { scene, camera, renderer, controls, init };
}

3.2 场景初始化与基础设置

typescript

// core/SceneManager.ts
export class SceneManager {
  private scene: THREE.Scene;
  private gridHelper: THREE.GridHelper;
  private axesHelper: THREE.AxesHelper;
  private lights: THREE.Light[] = [];
  
  constructor(scene: THREE.Scene) {
    this.scene = scene;
    this.initScene();
  }
  
  private initScene(): void {
    // 添加坐标轴辅助
    this.axesHelper = new THREE.AxesHelper(5);
    this.scene.add(this.axesHelper);
    
    // 添加网格辅助
    this.gridHelper = new THREE.GridHelper(10, 10);
    (this.gridHelper.material as THREE.Material).opacity = 0.25;
    (this.gridHelper.material as THREE.Material).transparent = true;
    this.scene.add(this.gridHelper);
    
    // 设置环境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    this.scene.add(ambientLight);
    this.lights.push(ambientLight);
    
    // 设置平行光
    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
    directionalLight.position.set(5, 10, 7.5);
    directionalLight.castShadow = true;
    
    // 设置阴影参数
    directionalLight.shadow.camera.near = 0.5;
    directionalLight.shadow.camera.far = 50;
    directionalLight.shadow.camera.left = -10;
    directionalLight.shadow.camera.right = 10;
    directionalLight.shadow.camera.top = 10;
    directionalLight.shadow.camera.bottom = -10;
    directionalLight.shadow.mapSize.width = 2048;
    directionalLight.shadow.mapSize.height = 2048;
    
    this.scene.add(directionalLight);
    this.lights.push(directionalLight);
  }
  
  public toggleGrid(visible: boolean): void {
    this.gridHelper.visible = visible;
  }
  
  public toggleAxes(visible: boolean): void {
    this.axesHelper.visible = visible;
  }
  
  public setBackground(color: string | number): void {
    this.scene.background = new THREE.Color(color);
  }
}

第四章:3D对象管理系统

4.1 对象管理器设计与实现

typescript

// core/ObjectManager.ts
import { EventEmitter } from 'events';
import * as THREE from 'three';

export interface Object3DWithMetadata extends THREE.Object3D {
  uuid: string;
  userData: {
    id: string;
    name: string;
    type: string;
    createdAt: number;
    updatedAt: number;
    properties: Record<string, any>;
  };
}

export class ObjectManager extends EventEmitter {
  private scene: THREE.Scene;
  private objects: Map<string, Object3DWithMetadata> = new Map();
  private selectedObjects: Set<string> = new Set();
  
  constructor(scene: THREE.Scene) {
    super();
    this.scene = scene;
  }
  
  public addObject(
    object: THREE.Object3D,
    options: {
      name?: string;
      type?: string;
      properties?: Record<string, any>;
    } = {}
  ): string {
    const objectWithMeta = object as Object3DWithMetadata;
    
    // 生成唯一ID
    const id = objectWithMeta.uuid || THREE.MathUtils.generateUUID();
    
    // 设置用户数据
    objectWithMeta.userData = {
      id,
      name: options.name || `Object_${Date.now()}`,
      type: options.type || 'Mesh',
      createdAt: Date.now(),
      updatedAt: Date.now(),
      properties: options.properties || {},
      ...object.userData
    };
    
    // 添加到场景和管理器
    this.scene.add(objectWithMeta);
    this.objects.set(id, objectWithMeta);
    
    // 触发事件
    this.emit('objectAdded', { object: objectWithMeta });
    
    return id;
  }
  
  public removeObject(id: string): boolean {
    const object = this.objects.get(id);
    if (!object) return false;
    
    // 从场景中移除
    this.scene.remove(object);
    
    // 从管理器中移除
    this.objects.delete(id);
    this.selectedObjects.delete(id);
    
    // 触发事件
    this.emit('objectRemoved', { id, object });
    
    return true;
  }
  
  public selectObject(id: string, multiSelect: boolean = false): void {
    if (!multiSelect) {
      this.clearSelection();
    }
    
    const object = this.objects.get(id);
    if (object) {
      this.selectedObjects.add(id);
      
      // 添加选择高亮效果
      this.addSelectionHighlight(object);
      
      this.emit('objectSelected', { id, object });
    }
  }
  
  public clearSelection(): void {
    // 移除所有高亮效果
    this.selectedObjects.forEach(id => {
      const object = this.objects.get(id);
      if (object) {
        this.removeSelectionHighlight(object);
      }
    });
    
    this.selectedObjects.clear();
    this.emit('selectionCleared');
  }
  
  private addSelectionHighlight(object: Object3DWithMetadata): void {
    // 创建边框几何体
    const geometry = (object as THREE.Mesh).geometry;
    if (geometry) {
      const edges = new THREE.EdgesGeometry(geometry);
      const line = new THREE.LineSegments(
        edges,
        new THREE.LineBasicMaterial({ color: 0x00ff00 })
      );
      
      line.userData.isSelectionHelper = true;
      line.scale.copy(object.scale);
      object.add(line);
    }
  }
  
  private removeSelectionHighlight(object: Object3DWithMetadata): void {
    const helpers = object.children.filter(
      child => child.userData.isSelectionHelper
    );
    
    helpers.forEach(helper => {
      object.remove(helper);
      (helper as THREE.LineSegments).geometry.dispose();
      (helper as THREE.LineSegments).material.dispose();
    });
  }
  
  public getSelectedObjects(): Object3DWithMetadata[] {
    return Array.from(this.selectedObjects)
      .map(id => this.objects.get(id))
      .filter(Boolean) as Object3DWithMetadata[];
  }
  
  public getObjectById(id: string): Object3DWithMetadata | undefined {
    return this.objects.get(id);
  }
  
  public getAllObjects(): Object3DWithMetadata[] {
    return Array.from(this.objects.values());
  }
  
  public updateObjectProperty(
    id: string,
    propertyPath: string,
    value: any
  ): void {
    const object = this.objects.get(id);
    if (!object) return;
    
    // 更新属性
    const keys = propertyPath.split('.');
    let target: any = object;
    
    for (let i = 0; i < keys.length - 1; i++) {
      target = target[keys[i]];
      if (!target) return;
    }
    
    target[keys[keys.length - 1]] = value;
    object.userData.updatedAt = Date.now();
    
    this.emit('objectUpdated', { id, propertyPath, value, object });
  }
}

4.2 几何体创建与管理

typescript

// utils/GeometryFactory.ts
import * as THREE from 'three';

export class GeometryFactory {
  public static createBox(options: {
    width?: number;
    height?: number;
    depth?: number;
    color?: number;
    position?: THREE.Vector3;
  } = {}): THREE.Mesh {
    const {
      width = 1,
      height = 1,
      depth = 1,
      color = 0x2194ce,
      position = new THREE.Vector3(0, 0, 0)
    } = options;
    
    const geometry = new THREE.BoxGeometry(width, height, depth);
    const material = new THREE.MeshStandardMaterial({
      color,
      roughness: 0.7,
      metalness: 0.2
    });
    
    const mesh = new THREE.Mesh(geometry, material);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.position.copy(position);
    
    return mesh;
  }
  
  public static createSphere(options: {
    radius?: number;
    segments?: number;
    color?: number;
    position?: THREE.Vector3;
  } = {}): THREE.Mesh {
    const {
      radius = 1,
      segments = 32,
      color = 0xce217a,
      position = new THREE.Vector3(0, 0, 0)
    } = options;
    
    const geometry = new THREE.SphereGeometry(radius, segments, segments);
    const material = new THREE.MeshStandardMaterial({
      color,
      roughness: 0.3,
      metalness: 0.8
    });
    
    const mesh = new THREE.Mesh(geometry, material);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.position.copy(position);
    
    return mesh;
  }
  
  public static createCylinder(options: {
    radius?: number;
    height?: number;
    segments?: number;
    color?: number;
    position?: THREE.Vector3;
  } = {}): THREE.Mesh {
    const {
      radius = 0.5,
      height = 1,
      segments = 32,
      color = 0x21ce54,
      position = new THREE.Vector3(0, 0, 0)
    } = options;
    
    const geometry = new THREE.CylinderGeometry(radius, radius, height, segments);
    const material = new THREE.MeshStandardMaterial({
      color,
      roughness: 0.5,
      metalness: 0.5
    });
    
    const mesh = new THREE.Mesh(geometry, material);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.position.copy(position);
    
    return mesh;
  }
  
  public static createPlane(options: {
    width?: number;
    height?: number;
    color?: number;
    position?: THREE.Vector3;
  } = {}): THREE.Mesh {
    const {
      width = 10,
      height = 10,
      color = 0xcccccc,
      position = new THREE.Vector3(0, 0, 0)
    } = options;
    
    const geometry = new THREE.PlaneGeometry(width, height);
    const material = new THREE.MeshStandardMaterial({
      color,
      side: THREE.DoubleSide,
      roughness: 0.8,
      metalness: 0.2
    });
    
    const mesh = new THREE.Mesh(geometry, material);
    mesh.receiveShadow = true;
    mesh.position.copy(position);
    mesh.rotation.x = -Math.PI / 2; // 水平放置
    
    return mesh;
  }
  
  public static createText(options: {
    text: string;
    font: THREE.Font;
    size?: number;
    height?: number;
    color?: number;
    position?: THREE.Vector3;
  }): THREE.Mesh {
    const {
      text,
      font,
      size = 1,
      height = 0.1,
      color = 0xffffff,
      position = new THREE.Vector3(0, 0, 0)
    } = options;
    
    const geometry = new THREE.TextGeometry(text, {
      font,
      size,
      height,
      curveSegments: 12,
      bevelEnabled: true,
      bevelThickness: 0.01,
      bevelSize: 0.01,
      bevelSegments: 3
    });
    
    const material = new THREE.MeshStandardMaterial({ color });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.copy(position);
    
    return mesh;
  }
}

第五章:编辑器UI界面设计

5.1 主界面布局与组件设计

vue

<!-- components/EditorMain.vue -->
<template>
  <div class="editor-container">
    <!-- 顶部工具栏 -->
    <EditorToolbar @tool-change="onToolChange" />
    
    <div class="editor-content">
      <!-- 左侧对象面板 -->
      <div class="editor-sidebar left">
        <ObjectHierarchyPanel 
          :objects="objects"
          @select="onObjectSelect"
          @add="onObjectAdd"
          @remove="onObjectRemove"
        />
        
        <PropertyEditorPanel 
          :selected-objects="selectedObjects"
          @property-change="onPropertyChange"
        />
      </div>
      
      <!-- 中央3D视图 -->
      <div class="editor-viewport">
        <Viewport3D 
          ref="viewportRef"
          @object-click="onObjectClick"
          @viewport-ready="onViewportReady"
        />
        
        <!-- 视图控制工具栏 -->
        <ViewportControls 
          :camera="camera"
          @view-change="onViewChange"
          @grid-toggle="onGridToggle"
          @axes-toggle="onAxesToggle"
        />
      </div>
      
      <!-- 右侧工具面板 -->
      <div class="editor-sidebar right">
        <MaterialEditorPanel 
          :selected-object="selectedObject"
          @material-change="onMaterialChange"
        />
        
        <LightEditorPanel 
          :lights="lights"
          @light-change="onLightChange"
          @light-add="onLightAdd"
        />
        
        <SceneSettingsPanel 
          :scene="scene"
          @setting-change="onSceneSettingChange"
        />
      </div>
    </div>
    
    <!-- 底部状态栏 -->
    <EditorStatusBar 
      :selected-count="selectedObjects.length"
      :object-count="objects.length"
      :fps="fps"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useEditorStore } from '@/stores/editor';
import { useThree } from '@/composables/useThree';

// 组件引用
const viewportRef = ref();
const containerRef = ref<HTMLElement>();

// 状态管理
const editorStore = useEditorStore();
const { scene, camera, renderer, controls } = useThree(containerRef);

const objects = ref([]);
const selectedObjects = ref([]);
const selectedObject = ref(null);
const lights = ref([]);
const fps = ref(0);

const onViewportReady = (viewport: any) => {
  // 初始化编辑器
  editorStore.initialize(viewport);
};

const onObjectClick = (object: any) => {
  editorStore.selectObject(object.id);
};

const onObjectAdd = (type: string) => {
  editorStore.addObject(type);
};

const onObjectRemove = (id: string) => {
  editorStore.removeObject(id);
};

// 性能监控
let frameCount = 0;
let lastTime = performance.now();

const updateFPS = () => {
  frameCount++;
  const currentTime = performance.now();
  
  if (currentTime - lastTime >= 1000) {
    fps.value = Math.round((frameCount * 1000) / (currentTime - lastTime));
    frameCount = 0;
    lastTime = currentTime;
  }
  
  requestAnimationFrame(updateFPS);
};

onMounted(() => {
  updateFPS();
});

onUnmounted(() => {
  // 清理资源
  editorStore.dispose();
});
</script>

<style scoped>
.editor-container {
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: #1e1e1e;
  color: #fff;
}

.editor-content {
  flex: 1;
  display: flex;
  overflow: hidden;
}

.editor-sidebar {
  width: 300px;
  background: #252526;
  border-right: 1px solid #333;
  display: flex;
  flex-direction: column;
}

.editor-sidebar.right {
  border-right: none;
  border-left: 1px solid #333;
}

.editor-viewport {
  flex: 1;
  position: relative;
  overflow: hidden;
}
</style>

5.2 工具栏组件实现

vue

<!-- components/EditorToolbar.vue -->
<template>
  <div class="editor-toolbar">
    <div class="toolbar-section">
      <!-- 文件操作 -->
      <div class="toolbar-group">
        <button 
          class="toolbar-btn"
          @click="$emit('new-scene')"
          title="新建场景"
        >
          <i class="icon-new"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="$emit('open-file')"
          title="打开文件"
        >
          <i class="icon-open"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="$emit('save-scene')"
          title="保存场景"
        >
          <i class="icon-save"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="$emit('export-scene')"
          title="导出场景"
        >
          <i class="icon-export"></i>
        </button>
      </div>
      
      <!-- 编辑工具 -->
      <div class="toolbar-group">
        <button 
          class="toolbar-btn"
          :class="{ active: activeTool === 'select' }"
          @click="setActiveTool('select')"
          title="选择工具 (Q)"
        >
          <i class="icon-select"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          :class="{ active: activeTool === 'move' }"
          @click="setActiveTool('move')"
          title="移动工具 (W)"
        >
          <i class="icon-move"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          :class="{ active: activeTool === 'rotate' }"
          @click="setActiveTool('rotate')"
          title="旋转工具 (E)"
        >
          <i class="icon-rotate"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          :class="{ active: activeTool === 'scale' }"
          @click="setActiveTool('scale')"
          title="缩放工具 (R)"
        >
          <i class="icon-scale"></i>
        </button>
      </div>
      
      <!-- 几何体创建 -->
      <div class="toolbar-group">
        <button 
          class="toolbar-btn"
          @click="addObject('box')"
          title="创建立方体"
        >
          <i class="icon-box"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="addObject('sphere')"
          title="创建球体"
        >
          <i class="icon-sphere"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="addObject('cylinder')"
          title="创建圆柱体"
        >
          <i class="icon-cylinder"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="addObject('plane')"
          title="创建平面"
        >
          <i class="icon-plane"></i>
        </button>
      </div>
      
      <!-- 视图控制 -->
      <div class="toolbar-group">
        <button 
          class="toolbar-btn"
          @click="setView('front')"
          title="前视图"
        >
          <i class="icon-view-front"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="setView('back')"
          title="后视图"
        >
          <i class="icon-view-back"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="setView('top')"
          title="顶视图"
        >
          <i class="icon-view-top"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="setView('perspective')"
          title="透视图"
        >
          <i class="icon-view-perspective"></i>
        </button>
      </div>
      
      <!-- 其他工具 -->
      <div class="toolbar-group">
        <button 
          class="toolbar-btn"
          @click="$emit('undo')"
          :disabled="!canUndo"
          title="撤销 (Ctrl+Z)"
        >
          <i class="icon-undo"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="$emit('redo')"
          :disabled="!canRedo"
          title="重做 (Ctrl+Y)"
        >
          <i class="icon-redo"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="$emit('toggle-grid')"
          title="显示/隐藏网格"
        >
          <i class="icon-grid"></i>
        </button>
        
        <button 
          class="toolbar-btn"
          @click="$emit('toggle-axes')"
          title="显示/隐藏坐标轴"
        >
          <i class="icon-axes"></i>
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';

const activeTool = ref('select');
const canUndo = ref(false);
const canRedo = ref(false);

const setActiveTool = (tool: string) => {
  activeTool.value = tool;
  emit('tool-change', tool);
};

const addObject = (type: string) => {
  emit('add-object', type);
};

const setView = (view: string) => {
  emit('view-change', view);
};

// 键盘快捷键支持
const handleKeyDown = (event: KeyboardEvent) => {
  // 防止在输入框中触发
  if (event.target instanceof HTMLInputElement || 
      event.target instanceof HTMLTextAreaElement) {
    return;
  }
  
  const key = event.key.toLowerCase();
  
  // 工具快捷键
  switch (key) {
    case 'q':
      setActiveTool('select');
      event.preventDefault();
      break;
    case 'w':
      setActiveTool('move');
      event.preventDefault();
      break;
    case 'e':
      setActiveTool('rotate');
      event.preventDefault();
      break;
    case 'r':
      setActiveTool('scale');
      event.preventDefault();
      break;
    case 'z':
      if (event.ctrlKey || event.metaKey) {
        if (event.shiftKey) {
          emit('redo');
        } else {
          emit('undo');
        }
        event.preventDefault();
      }
      break;
    case 'y':
      if (event.ctrlKey || event.metaKey) {
        emit('redo');
        event.preventDefault();
      }
      break;
    case 'delete':
    case 'backspace':
      emit('delete-selected');
      event.preventDefault();
      break;
  }
};

onMounted(() => {
  document.addEventListener('keydown', handleKeyDown);
});

onUnmounted(() => {
  document.removeEventListener('keydown', handleKeyDown);
});
</script>

<style scoped>
.editor-toolbar {
  background: #333;
  border-bottom: 1px solid #444;
  padding: 4px;
  display: flex;
  align-items: center;
}

.toolbar-section {
  display: flex;
  align-items: center;
  flex: 1;
}

.toolbar-group {
  display: flex;
  margin-right: 16px;
  padding-right: 16px;
  border-right: 1px solid #444;
}

.toolbar-group:last-child {
  border-right: none;
  margin-right: 0;
}

.toolbar-btn {
  width: 32px;
  height: 32px;
  margin: 0 2px;
  background: #444;
  border: 1px solid #555;
  border-radius: 4px;
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s;
}

.toolbar-btn:hover {
  background: #555;
  border-color: #666;
}

.toolbar-btn.active {
  background: #007acc;
  border-color: #0098ff;
}

.toolbar-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toolbar-btn:disabled:hover {
  background: #444;
  border-color: #555;
}

/* 图标样式 */
.icon-new, .icon-open, .icon-save, .icon-export,
.icon-select, .icon-move, .icon-rotate, .icon-scale,
.icon-box, .icon-sphere, .icon-cylinder, .icon-plane,
.icon-view-front, .icon-view-back, .icon-view-top, .icon-view-perspective,
.icon-undo, .icon-redo, .icon-grid, .icon-axes {
  display: inline-block;
  width: 16px;
  height: 16px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
}

/* 实际项目中应使用字体图标或SVG图标 */
.icon-new { background-image: url('data:image/svg+xml,...'); }
/* 其他图标类似定义 */
</style>

第六章:交互与变换控制

6.1 对象选择与射线检测

typescript

// utils/RaycasterUtils.ts
import * as THREE from 'three';

export class RaycasterUtils {
  private raycaster: THREE.Raycaster;
  private mouse: THREE.Vector2;
  
  constructor() {
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
  }
  
  public setFromCamera(
    mouseCoords: { x: number; y: number },
    camera: THREE.Camera
  ): void {
    // 将鼠标坐标归一化为 -1 到 1 的范围
    this.mouse.x = (mouseCoords.x / window.innerWidth) * 2 - 1;
    this.mouse.y = -(mouseCoords.y / window.innerHeight) * 2 + 1;
    
    this.raycaster.setFromCamera(this.mouse, camera);
  }
  
  public intersectObjects(
    objects: THREE.Object3D[],
    recursive: boolean = true
  ): THREE.Intersection[] {
    return this.raycaster.intersectObjects(objects, recursive);
  }
  
  public getFirstIntersection(
    objects: THREE.Object3D[],
    recursive: boolean = true
  ): THREE.Intersection | null {
    const intersections = this.intersectObjects(objects, recursive);
    return intersections.length > 0 ? intersections[0] : null;
  }
  
  public isObjectIntersected(
    object: THREE.Object3D,
    camera: THREE.Camera,
    mouseCoords: { x: number; y: number }
  ): boolean {
    this.setFromCamera(mouseCoords, camera);
    const intersections = this.raycaster.intersectObject(object, true);
    return intersections.length > 0;
  }
  
  public getWorldPositionAtMouse(
    camera: THREE.Camera,
    mouseCoords: { x: number; y: number },
    distance: number = 100
  ): THREE.Vector3 {
    this.setFromCamera(mouseCoords, camera);
    
    // 创建一个从相机出发的射线
    const direction = new THREE.Vector3(0, 0, -1);
    direction.unproject(camera);
    direction.sub(camera.position).normalize();
    
    // 计算射线与指定距离平面的交点
    const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
    const result = new THREE.Vector3();
    this.raycaster.ray.intersectPlane(plane, result);
    
    return result;
  }
}

6.2 变换控制器实现

typescript

// core/TransformControls.ts
import * as THREE from 'three';
import { TransformControls as ThreeTransformControls } from 'three/examples/jsm/controls/TransformControls';

export class TransformControls extends ThreeTransformControls {
  private editor: any;
  private selectedObjects: THREE.Object3D[] = [];
  
  constructor(camera: THREE.Camera, domElement: HTMLElement, editor: any) {
    super(camera, domElement);
    this.editor = editor;
    
    this.initControls();
    this.setupEventListeners();
  }
  
  private initControls(): void {
    // 设置控制器的样式和大小
    this.setSize(1);
    
    // 设置变换空间
    this.space = 'world';
    
    // 启用网格吸附
    this.showX = true;
    this.showY = true;
    this.showZ = true;
    
    // 设置吸附参数
    this.translationSnap = 1;
    this.rotationSnap = Math.PI / 8;
    this.scaleSnap = 0.25;
  }
  
  public attachObject(object: THREE.Object3D): void {
    super.attach(object);
    this.selectedObjects = [object];
  }
  
  public attachObjects(objects: THREE.Object3D[]): void {
    if (objects.length === 1) {
      this.attachObject(objects[0]);
    } else if (objects.length > 1) {
      // 对于多个对象,创建一个虚拟父对象
      const group = new THREE.Group();
      objects.forEach(obj => {
        group.add(obj.clone());
      });
      
      // 计算组的中心点
      const center = new THREE.Vector3();
      objects.forEach(obj => {
        center.add(obj.position);
      });
      center.divideScalar(objects.length);
      group.position.copy(center);
      
      this.attachObject(group);
      this.selectedObjects = objects;
    }
  }
  
  private setupEventListeners(): void {
    // 变换开始
    this.addEventListener('mouseDown', (event) => {
      this.editor.setTransformMode(this.mode);
      this.editor.setTransformSpace(this.space);
    });
    
    // 变换中
    this.addEventListener('objectChange', (event) => {
      const attachedObject = this.object;
      
      if (this.selectedObjects.length === 1) {
        // 单个对象
        const selected = this.selectedObjects[0];
        
        if (this.mode === 'translate') {
          selected.position.copy(attachedObject.position);
        } else if (this.mode === 'rotate') {
          selected.rotation.copy(attachedObject.rotation);
        } else if (this.mode === 'scale') {
          selected.scale.copy(attachedObject.scale);
        }
      } else if (this.selectedObjects.length > 1) {
        // 多个对象
        const deltaPosition = attachedObject.position.clone();
        
        this.selectedObjects.forEach(obj => {
          if (this.mode === 'translate') {
            obj.position.add(deltaPosition);
          }
          // 旋转和缩放的实现较为复杂,需要相对变换
        });
      }
    });
    
    // 变换结束
    this.addEventListener('mouseUp', (event) => {
      // 记录变换操作到历史记录
      this.editor.recordTransform();
      
      // 如果是多对象变换,清理虚拟对象
      if (this.selectedObjects.length > 1) {
        this.detach();
      }
    });
  }
  
  public setTransformMode(mode: 'translate' | 'rotate' | 'scale'): void {
    this.mode = mode;
    
    // 更新控制器显示
    switch (mode) {
      case 'translate':
        this.showX = this.showY = this.showZ = true;
        break;
      case 'rotate':
        this.showX = this.showY = this.showZ = true;
        break;
      case 'scale':
        this.showX = this.showY = this.showZ = true;
        break;
    }
  }
  
  public setTransformSpace(space: 'world' | 'local'): void {
    this.space = space;
  }
  
  public setSnapEnabled(enabled: boolean): void {
    if (enabled) {
      this.translationSnap = 1;
      this.rotationSnap = Math.PI / 8;
      this.scaleSnap = 0.25;
    } else {
      this.translationSnap = null;
      this.rotationSnap = null;
      this.scaleSnap = null;
    }
  }
  
  public dispose(): void {
    this.removeAllEventListeners();
    super.dispose();
  }
}

第七章:材质与纹理编辑系统

7.1 材质编辑器实现

vue

<!-- components/MaterialEditor.vue -->
<template>
  <div class="material-editor">
    <div class="material-header">
      <h3>材质编辑器</h3>
      <div class="material-actions">
        <button @click="createNewMaterial" class="btn-small">
          新建材质
        </button>
        <button @click="saveMaterial" class="btn-small">
          保存材质
        </button>
      </div>
    </div>
    
    <div v-if="selectedMaterial" class="material-properties">
      <!-- 基本信息 -->
      <div class="property-group">
        <h4>基本信息</h4>
        <div class="property-row">
          <label>材质名称</label>
          <input 
            v-model="selectedMaterial.name"
            type="text"
            @change="updateMaterial"
          />
        </div>
        
        <div class="property-row">
          <label>材质类型</label>
          <select 
            v-model="selectedMaterial.type"
            @change="onMaterialTypeChange"
          >
            <option value="MeshStandardMaterial">标准材质</option>
            <option value="MeshPhongMaterial">Phong材质</option>
            <option value="MeshLambertMaterial">Lambert材质</option>
            <option value="MeshBasicMaterial">基础材质</option>
            <option value="MeshPhysicalMaterial">物理材质</option>
          </select>
        </div>
      </div>
      
      <!-- 基础颜色 -->
      <div class="property-group">
        <h4>基础颜色</h4>
        <div class="property-row">
          <label>颜色</label>
          <div class="color-picker-wrapper">
            <input 
              type="color"
              :value="selectedMaterial.color"
              @input="updateMaterialColor"
              class="color-picker"
            />
            <input 
              type="text"
              v-model="selectedMaterial.color"
              @change="updateMaterialColor"
              class="color-input"
            />
          </div>
        </div>
        
        <div class="property-row">
          <label>不透明度</label>
          <input 
            v-model="selectedMaterial.opacity"
            type="range"
            min="0"
            max="1"
            step="0.01"
            @input="updateMaterial"
          />
          <span>{{ selectedMaterial.opacity.toFixed(2) }}</span>
        </div>
        
        <div class="property-row">
          <label>透明度</label>
          <input 
            v-model="selectedMaterial.transparent"
            type="checkbox"
            @change="updateMaterial"
          />
        </div>
      </div>
      
      <!-- 纹理贴图 -->
      <div class="property-group">
        <h4>纹理贴图</h4>
        
        <!-- 颜色贴图 -->
        <div class="texture-slot">
          <div class="texture-header">
            <span>颜色贴图</span>
            <button 
              @click="openTexturePicker('map')"
              class="btn-texture"
            >
              {{ selectedMaterial.map ? '更换' : '添加' }}
            </button>
          </div>
          
          <div v-if="selectedMaterial.map" class="texture-preview">
            <img :src="getTexturePreview(selectedMaterial.map)" />
            <button @click="removeTexture('map')" class="btn-remove">
              ×
            </button>
          </div>
        </div>
        
        <!-- 法线贴图 -->
        <div class="texture-slot">
          <div class="texture-header">
            <span>法线贴图</span>
            <button 
              @click="openTexturePicker('normalMap')"
              class="btn-texture"
            >
              {{ selectedMaterial.normalMap ? '更换' : '添加' }}
            </button>
          </div>
          
          <div v-if="selectedMaterial.normalMap" class="texture-preview">
            <img :src="getTexturePreview(selectedMaterial.normalMap)" />
            <button @click="removeTexture('normalMap')" class="btn-remove">
              ×
            </button>
          </div>
        </div>
      </div>
      
      <!-- 物理属性 -->
      <div v-if="selectedMaterial.type === 'MeshPhysicalMaterial'" 
           class="property-group">
        <h4>物理属性</h4>
        
        <div class="property-row">
          <label>粗糙度</label>
          <input 
            v-model="selectedMaterial.roughness"
            type="range"
            min="0"
            max="1"
            step="0.01"
            @input="updateMaterial"
          />
          <span>{{ selectedMaterial.roughness.toFixed(2) }}</span>
        </div>
        
        <div class="property-row">
          <label>金属度</label>
          <input 
            v-model="selectedMaterial.metalness"
            type="range"
            min="0"
            max="1"
            step="0.01"
            @input="updateMaterial"
          />
          <span>{{ selectedMaterial.metalness.toFixed(2) }}</span>
        </div>
        
        <div class="property-row">
          <label>清漆度</label>
          <input 
            v-model="selectedMaterial.clearcoat"
            type="range"
            min="0"
            max="1"
            step="0.01"
            @input="updateMaterial"
          />
          <span>{{ selectedMaterial.clearcoat.toFixed(2) }}</span>
        </div>
      </div>
      
      <!-- 高级设置 -->
      <div class="property-group">
        <h4>高级设置</h4>
        
        <div class="property-row">
          <label>侧面渲染</label>
          <select 
            v-model="selectedMaterial.side"
            @change="updateMaterial"
          >
            <option value="0">前面</option>
            <option value="1">背面</option>
            <option value="2">双面</option>
          </select>
        </div>
        
        <div class="property-row">
          <label>阴影面</label>
          <select 
            v-model="selectedMaterial.shadowSide"
            @change="updateMaterial"
          >
            <option value="null">自动</option>
            <option value="0">前面</option>
            <option value="1">背面</option>
            <option value="2">双面</option>
          </select>
        </div>
        
        <div class="property-row">
          <label>混合模式</label>
          <select 
            v-model="selectedMaterial.blending"
            @change="updateMaterial"
          >
            <option value="0">正常</option>
            <option value="1">叠加</option>
            <option value="2">相乘</option>
            <option value="3">相加</option>
          </select>
        </div>
      </div>
    </div>
    
    <!-- 材质库 -->
    <div class="material-library">
      <h4>材质库</h4>
      <div class="material-list">
        <div 
          v-for="material in materialLibrary"
          :key="material.uuid"
          class="material-item"
          :class="{ active: selectedMaterial?.uuid === material.uuid }"
          @click="selectMaterial(material)"
        >
          <div class="material-preview" :style="{
            background: material.previewColor || '#888'
          }"></div>
          <span class="material-name">{{ material.name }}</span>
        </div>
      </div>
    </div>
    
    <!-- 纹理选择器 -->
    <TexturePicker 
      v-if="showTexturePicker"
      :active-slot="activeTextureSlot"
      @select="onTextureSelect"
      @close="showTexturePicker = false"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import * as THREE from 'three';
import { useTextureLoader } from '@/composables/useTextureLoader';

interface MaterialData {
  uuid: string;
  name: string;
  type: string;
  color: string;
  opacity: number;
  transparent: boolean;
  map?: string;
  normalMap?: string;
  roughness: number;
  metalness: number;
  side: string;
  [key: string]: any;
}

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

const emit = defineEmits<{
  'material-change': [material: any];
}>();

const textureLoader = useTextureLoader();
const selectedMaterial = ref<MaterialData | null>(null);
const materialLibrary = ref<MaterialData[]>([]);
const showTexturePicker = ref(false);
const activeTextureSlot = ref<string>('');

const createNewMaterial = () => {
  const newMaterial: MaterialData = {
    uuid: THREE.MathUtils.generateUUID(),
    name: `材质_${materialLibrary.value.length + 1}`,
    type: 'MeshStandardMaterial',
    color: '#ffffff',
    opacity: 1,
    transparent: false,
    roughness: 0.7,
    metalness: 0.2,
    side: '0'
  };
  
  materialLibrary.value.push(newMaterial);
  selectMaterial(newMaterial);
};

const selectMaterial = (material: MaterialData) => {
  selectedMaterial.value = { ...material };
  
  // 应用材质到选中对象
  if (props.selectedObject) {
    applyMaterialToObject(props.selectedObject, material);
  }
};

const onMaterialTypeChange = () => {
  if (!selectedMaterial.value) return;
  
  // 根据材质类型设置默认值
  switch (selectedMaterial.value.type) {
    case 'MeshStandardMaterial':
      selectedMaterial.value.roughness = 0.7;
      selectedMaterial.value.metalness = 0.2;
      break;
    case 'MeshPhongMaterial':
      selectedMaterial.value.shininess = 30;
      break;
    case 'MeshBasicMaterial':
      // 基础材质没有特殊属性
      break;
  }
  
  updateMaterial();
};

const updateMaterial = () => {
  if (!selectedMaterial.value) return;
  
  emit('material-change', selectedMaterial.value);
};

const updateMaterialColor = (event: Event) => {
  if (!selectedMaterial.value) return;
  
  if (event.target instanceof HTMLInputElement) {
    if (event.target.type === 'color') {
      selectedMaterial.value.color = event.target.value;
    }
  }
  
  updateMaterial();
};

const openTexturePicker = (slot: string) => {
  activeTextureSlot.value = slot;
  showTexturePicker.value = true;
};

const onTextureSelect = (textureData: { url: string; name: string }) => {
  if (!selectedMaterial.value) return;
  
  selectedMaterial.value[activeTextureSlot.value] = textureData.url;
  updateMaterial();
  showTexturePicker.value = false;
};

const removeTexture = (slot: string) => {
  if (!selectedMaterial.value) return;
  
  delete selectedMaterial.value[slot];
  updateMaterial();
};

const getTexturePreview = (textureUrl: string): string => {
  // 这里应该实现纹理预览的生成
  // 可以是缩略图或base64编码
  return textureUrl;
};

const applyMaterialToObject = (object: any, materialData: MaterialData) => {
  if (!object || !object.material) return;
  
  // 根据材质数据创建Three.js材质
  let material: THREE.Material;
  
  switch (materialData.type) {
    case 'MeshStandardMaterial':
      material = new THREE.MeshStandardMaterial({
        color: new THREE.Color(materialData.color),
        opacity: materialData.opacity,
        transparent: materialData.transparent,
        roughness: materialData.roughness,
        metalness: materialData.metalness,
        side: parseInt(materialData.side)
      });
      break;
    case 'MeshPhongMaterial':
      material = new THREE.MeshPhongMaterial({
        color: new THREE.Color(materialData.color),
        opacity: materialData.opacity,
        transparent: materialData.transparent,
        shininess: materialData.shininess || 30,
        side: parseInt(materialData.side)
      });
      break;
    default:
      material = new THREE.MeshBasicMaterial({
        color: new THREE.Color(materialData.color),
        opacity: materialData.opacity,
        transparent: materialData.transparent,
        side: parseInt(materialData.side)
      });
  }
  
  // 加载纹理
  if (materialData.map) {
    textureLoader.load(materialData.map, (texture) => {
      (material as any).map = texture;
      (material as any).needsUpdate = true;
    });
  }
  
  if (materialData.normalMap) {
    textureLoader.load(materialData.normalMap, (texture) => {
      (material as any).normalMap = texture;
      (material as any).needsUpdate = true;
    });
  }
  
  object.material = material;
  object.material.needsUpdate = true;
};

const saveMaterial = () => {
  if (!selectedMaterial.value) return;
  
  // 保存材质到本地存储或服务器
  const materials = JSON.parse(localStorage.getItem('materialLibrary') || '[]');
  const index = materials.findIndex((m: any) => m.uuid === selectedMaterial.value?.uuid);
  
  if (index >= 0) {
    materials[index] = selectedMaterial.value;
  } else {
    materials.push(selectedMaterial.value);
  }
  
  localStorage.setItem('materialLibrary', JSON.stringify(materials));
};

// 初始化材质库
onMounted(() => {
  const savedMaterials = localStorage.getItem('materialLibrary');
  if (savedMaterials) {
    materialLibrary.value = JSON.parse(savedMaterials);
  }
  
  // 添加默认材质
  if (materialLibrary.value.length === 0) {
    const defaultMaterials = [
      {
        uuid: THREE.MathUtils.generateUUID(),
        name: '默认金属',
        type: 'MeshStandardMaterial',
        color: '#aaaaaa',
        opacity: 1,
        transparent: false,
        roughness: 0.2,
        metalness: 0.8,
        side: '0'
      },
      {
        uuid: THREE.MathUtils.generateUUID(),
        name: '默认塑料',
        type: 'MeshStandardMaterial',
        color: '#ffffff',
        opacity: 1,
        transparent: false,
        roughness: 0.5,
        metalness: 0,
        side: '0'
      },
      {
        uuid: THREE.MathUtils.generateUUID(),
        name: '默认玻璃',
        type: 'MeshPhysicalMaterial',
        color: '#ffffff',
        opacity: 0.8,
        transparent: true,
        roughness: 0,
        metalness: 0,
        side: '0',
        transmission: 1,
        ior: 1.5
      }
    ];
    
    materialLibrary.value = defaultMaterials;
    localStorage.setItem('materialLibrary', JSON.stringify(defaultMaterials));
  }
});
</script>

<style scoped>
.material-editor {
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #2d2d2d;
  color: #fff;
}

.material-header {
  padding: 12px;
  border-bottom: 1px solid #444;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.material-actions {
  display: flex;
  gap: 8px;
}

.btn-small {
  padding: 4px 8px;
  background: #444;
  border: 1px solid #555;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}

.btn-small:hover {
  background: #555;
}

.material-properties {
  flex: 1;
  overflow-y: auto;
  padding: 12px;
}

.property-group {
  margin-bottom: 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid #444;
}

.property-group h4 {
  margin: 0 0 12px 0;
  font-size: 14px;
  color: #ccc;
}

.property-row {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
}

.property-row label {
  width: 100px;
  font-size: 12px;
  color: #aaa;
}

.property-row input[type="range"] {
  flex: 1;
  margin: 0 8px;
}

.property-row input[type="text"],
.property-row select {
  flex: 1;
  background: #444;
  border: 1px solid #555;
  color: #fff;
  padding: 4px 8px;
  border-radius: 4px;
}

.color-picker-wrapper {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
}

.color-picker {
  width: 32px;
  height: 32px;
  border: none;
  background: transparent;
  cursor: pointer;
}

.color-input {
  flex: 1;
}

.texture-slot {
  margin-bottom: 12px;
}

.texture-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 4px;
}

.btn-texture {
  padding: 2px 8px;
  background: #444;
  border: 1px solid #555;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  font-size: 11px;
}

.texture-preview {
  position: relative;
  width: 100%;
  height: 60px;
  background: #333;
  border-radius: 4px;
  overflow: hidden;
}

.texture-preview img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.btn-remove {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  background: #f00;
  color: #fff;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.material-library {
  padding: 12px;
  border-top: 1px solid #444;
}

.material-list {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 8px;
}

.material-item {
  background: #444;
  border-radius: 4px;
  padding: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.material-item:hover {
  background: #555;
}

.material-item.active {
  background: #007acc;
  border: 1px solid #0098ff;
}

.material-preview {
  width: 100%;
  height: 40px;
  border-radius: 4px;
  margin-bottom: 4px;
}

.material-name {
  font-size: 11px;
  color: #ccc;
  text-align: center;
  display: block;
}
</style>

7.2 纹理加载与管理系统

typescript

// composables/useTextureLoader.ts
import { ref } from 'vue';
import * as THREE from 'three';

export interface TextureAsset {
  id: string;
  name: string;
  url: string;
  type: 'color' | 'normal' | 'roughness' | 'metalness' | 'ao' | 'emissive';
  thumbnail?: string;
  size: { width: number; height: number };
  format: string;
  createdAt: number;
}

export function useTextureLoader() {
  const loader = new THREE.TextureLoader();
  const textureCache = new Map<string, THREE.Texture>();
  const textureAssets = ref<TextureAsset[]>([]);
  
  // 加载纹理
  const load = (url: string): Promise<THREE.Texture> => {
    return new Promise((resolve, reject) => {
      // 检查缓存
      if (textureCache.has(url)) {
        resolve(textureCache.get(url)!);
        return;
      }
      
      loader.load(
        url,
        (texture) => {
          textureCache.set(url, texture);
          resolve(texture);
        },
        undefined,
        (error) => {
          console.error('纹理加载失败:', url, error);
          reject(error);
        }
      );
    });
  };
  
  // 从文件加载纹理
  const loadFromFile = (file: File): Promise<TextureAsset> => {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      
      reader.onload = async (event) => {
        try {
          const url = event.target?.result as string;
          
          // 创建图片元素来获取尺寸
          const img = new Image();
          img.onload = () => {
            const textureAsset: TextureAsset = {
              id: THREE.MathUtils.generateUUID(),
              name: file.name,
              url,
              type: detectTextureType(file.name),
              size: { width: img.width, height: img.height },
              format: getTextureFormat(file.name),
              createdAt: Date.now()
            };
            
            // 生成缩略图
            generateThumbnail(img).then(thumbnail => {
              textureAsset.thumbnail = thumbnail;
              textureAssets.value.push(textureAsset);
              saveToLocalStorage(textureAsset);
              resolve(textureAsset);
            });
          };
          
          img.src = url;
        } catch (error) {
          reject(error);
        }
      };
      
      reader.onerror = (error) => reject(error);
      reader.readAsDataURL(file);
    });
  };
  
  // 生成缩略图
  const generateThumbnail = (img: HTMLImageElement): Promise<string> => {
    return new Promise((resolve) => {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      
      if (!ctx) {
        resolve('');
        return;
      }
      
      // 缩略图尺寸
      const maxSize = 128;
      let width = img.width;
      let height = img.height;
      
      if (width > height) {
        if (width > maxSize) {
          height = (height * maxSize) / width;
          width = maxSize;
        }
      } else {
        if (height > maxSize) {
          width = (width * maxSize) / height;
          height = maxSize;
        }
      }
      
      canvas.width = width;
      canvas.height = height;
      
      ctx.drawImage(img, 0, 0, width, height);
      resolve(canvas.toDataURL('image/jpeg', 0.9));
    });
  };
  
  // 检测纹理类型
  const detectTextureType = (filename: string): TextureAsset['type'] => {
    const name = filename.toLowerCase();
    
    if (name.includes('normal') || name.includes('nrm') || name.includes('_n')) {
      return 'normal';
    } else if (name.includes('roughness') || name.includes('rgh') || name.includes('_r')) {
      return 'roughness';
    } else if (name.includes('metal') || name.includes('mtl') || name.includes('_m')) {
      return 'metalness';
    } else if (name.includes('ao') || name.includes('ambient') || name.includes('_ao')) {
      return 'ao';
    } else if (name.includes('emissive') || name.includes('emit') || name.includes('_e')) {
      return 'emissive';
    } else {
      return 'color';
    }
  };
  
  // 获取纹理格式
  const getTextureFormat = (filename: string): string => {
    const extension = filename.split('.').pop()?.toLowerCase() || '';
    
    const formats: Record<string, string> = {
      'jpg': 'JPEG',
      'jpeg': 'JPEG',
      'png': 'PNG',
      'webp': 'WebP',
      'tga': 'TGA',
      'bmp': 'BMP'
    };
    
    return formats[extension] || 'Unknown';
  };
  
  // 保存到本地存储
  const saveToLocalStorage = (asset: TextureAsset): void => {
    const key = 'textureLibrary';
    const existing = JSON.parse(localStorage.getItem(key) || '[]');
    existing.push(asset);
    localStorage.setItem(key, JSON.stringify(existing));
  };
  
  // 从本地存储加载
  const loadFromLocalStorage = (): void => {
    const key = 'textureLibrary';
    const saved = localStorage.getItem(key);
    
    if (saved) {
      textureAssets.value = JSON.parse(saved);
    }
  };
  
  // 清理缓存
  const clearCache = (): void => {
    textureCache.forEach(texture => {
      texture.dispose();
    });
    textureCache.clear();
  };
  
  // 初始化
  loadFromLocalStorage();
  
  return {
    load,
    loadFromFile,
    textureAssets,
    clearCache
  };
}

第八章:导入导出与序列化

8.1 GLTF/GLB模型导入导出

typescript

// utils/ModelIO.ts
import * as THREE from 'three';
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter';
import { saveAs } from 'file-saver';

export class ModelIO {
  private gltfLoader: GLTFLoader;
  private dracoLoader: DRACOLoader;
  private gltfExporter: GLTFExporter;
  
  constructor() {
    this.gltfLoader = new GLTFLoader();
    
    // 初始化DRACO解码器(用于压缩模型)
    this.dracoLoader = new DRACOLoader();
    this.dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/');
    this.gltfLoader.setDRACOLoader(this.dracoLoader);
    
    this.gltfExporter = new GLTFExporter();
  }
  
  /**
   * 加载GLTF/GLB模型
   */
  public async loadGLTF(
    url: string,
    onProgress?: (progress: number) => void
  ): Promise<GLTF> {
    return new Promise((resolve, reject) => {
      this.gltfLoader.load(
        url,
        (gltf) => {
          // 处理加载的模型
          this.processLoadedModel(gltf.scene);
          resolve(gltf);
        },
        (progress) => {
          if (onProgress) {
            const percent = (progress.loaded / progress.total) * 100;
            onProgress(percent);
          }
        },
        (error) => {
          console.error('GLTF加载失败:', error);
          reject(error);
        }
      );
    });
  }
  
  /**
   * 从文件加载GLTF/GLB
   */
  public async loadGLTFFromFile(file: File): Promise<GLTF> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      
      reader.onload = (event) => {
        const arrayBuffer = event.target?.result as ArrayBuffer;
        
        this.gltfLoader.parse(
          arrayBuffer,
          '',
          (gltf) => {
            this.processLoadedModel(gltf.scene);
            resolve(gltf);
          },
          (error) => {
            console.error('GLTF解析失败:', error);
            reject(error);
          }
        );
      };
      
      reader.onerror = (error) => reject(error);
      reader.readAsArrayBuffer(file);
    });
  }
  
  /**
   * 处理加载的模型
   */
  private processLoadedModel(scene: THREE.Group): void {
    scene.traverse((object) => {
      // 确保所有网格都能投射和接收阴影
      if (object instanceof THREE.Mesh) {
        object.castShadow = true;
        object.receiveShadow = true;
        
        // 确保材质是标准材质
        if (object.material) {
          if (!(object.material instanceof THREE.MeshStandardMaterial)) {
            object.material = this.convertToStandardMaterial(object.material);
          }
        }
      }
      
      // 添加用户数据
      object.userData = {
        ...object.userData,
        imported: true,
        importTime: Date.now()
      };
    });
  }
  
  /**
   * 转换材质为标准材质
   */
  private convertToStandardMaterial(material: THREE.Material): THREE.MeshStandardMaterial {
    const newMaterial = new THREE.MeshStandardMaterial();
    
    // 复制通用属性
    newMaterial.color.copy((material as any).color || new THREE.Color(0xffffff));
    newMaterial.opacity = (material as any).opacity || 1;
    newMaterial.transparent = (material as any).transparent || false;
    
    // 复制纹理
    const sourceMat = material as any;
    if (sourceMat.map) newMaterial.map = sourceMat.map;
    if (sourceMat.normalMap) newMaterial.normalMap = sourceMat.normalMap;
    if (sourceMat.roughnessMap) newMaterial.roughnessMap = sourceMat.roughnessMap;
    if (sourceMat.metalnessMap) newMaterial.metalnessMap = sourceMat.metalnessMap;
    if (sourceMat.aoMap) newMaterial.aoMap = sourceMat.aoMap;
    if (sourceMat.emissiveMap) newMaterial.emissiveMap = sourceMat.emissiveMap;
    
    // 设置默认值
    newMaterial.roughness = sourceMat.roughness || 0.7;
    newMaterial.metalness = sourceMat.metalness || 0.2;
    
    return newMaterial;
  }
  
  /**
   * 导出为GLTF
   */
  public async exportGLTF(
    scene: THREE.Object3D,
    options: {
      binary?: boolean;
      trs?: boolean;
      onlyVisible?: boolean;
      truncateDrawRange?: boolean;
    } = {}
  ): Promise<ArrayBuffer | string> {
    return new Promise((resolve, reject) => {
      const exportOptions = {
        binary: options.binary ?? false,
        trs: options.trs ?? false,
        onlyVisible: options.onlyVisible ?? true,
        truncateDrawRange: options.truncateDrawRange ?? true,
        animations: this.extractAnimations(scene)
      };
      
      this.gltfExporter.parse(
        scene,
        (result) => {
          if (exportOptions.binary) {
            // GLB格式
            const blob = new Blob([result as ArrayBuffer], {
              type: 'model/gltf-binary'
            });
            saveAs(blob, 'scene.glb');
            resolve(result as ArrayBuffer);
          } else {
            // GLTF格式(JSON)
            const jsonString = JSON.stringify(result, null, 2);
            const blob = new Blob([jsonString], {
              type: 'model/gltf+json'
            });
            saveAs(blob, 'scene.gltf');
            resolve(jsonString);
          }
        },
        (error) => {
          console.error('GLTF导出失败:', error);
          reject(error);
        },
        exportOptions
      );
    });
  }
  
  /**
   * 提取场景中的动画
   */
  private extractAnimations(scene: THREE.Object3D): THREE.AnimationClip[] {
    const animations: THREE.AnimationClip[] = [];
    
    scene.traverse((object) => {
      if (object instanceof THREE.SkinnedMesh && object.geometry.animations) {
        animations.push(...object.geometry.animations);
      }
      
      if (object.animations) {
        animations.push(...object.animations);
      }
    });
    
    // 去重
    const uniqueAnimations = animations.filter((animation, index, self) =>
      index === self.findIndex((a) => a.name === animation.name)
    );
    
    return uniqueAnimations;
  }
  
  /**
   * 导出为OBJ格式
   */
  public exportOBJ(scene: THREE.Object3D): string {
    let objContent = '# Exported from 3D Editor\n';
    let mtlContent = '# Exported from 3D Editor\n';
    
    let vertexOffset = 0;
    let uvOffset = 0;
    let normalOffset = 0;
    let materialIndex = 0;
    
    const materials: Map<THREE.Material, string> = new Map();
    
    scene.traverse((object) => {
      if (object instanceof THREE.Mesh && object.visible) {
        const geometry = object.geometry;
        const material = object.material;
        
        // 生成材质名称
        const materialName = `material_${materialIndex++}`;
        materials.set(material, materialName);
        
        // 添加材质到MTL
        mtlContent += `\nnewmtl ${materialName}\n`;
        
        if (material instanceof THREE.MeshStandardMaterial) {
          mtlContent += `Ka ${material.color.r} ${material.color.g} ${material.color.b}\n`;
          mtlContent += `Kd ${material.color.r} ${material.color.g} ${material.color.b}\n`;
          mtlContent += `Ns ${material.roughness * 1000}\n`;
          mtlContent += `d ${material.opacity}\n`;
        }
        
        // 添加顶点
        const positionAttribute = geometry.getAttribute('position');
        for (let i = 0; i < positionAttribute.count; i++) {
          const x = positionAttribute.getX(i);
          const y = positionAttribute.getY(i);
          const z = positionAttribute.getZ(i);
          objContent += `v ${x} ${y} ${z}\n`;
        }
        
        // 添加UV
        const uvAttribute = geometry.getAttribute('uv');
        if (uvAttribute) {
          for (let i = 0; i < uvAttribute.count; i++) {
            const u = uvAttribute.getX(i);
            const v = uvAttribute.getY(i);
            objContent += `vt ${u} ${v}\n`;
          }
        }
        
        // 添加法线
        const normalAttribute = geometry.getAttribute('normal');
        if (normalAttribute) {
          for (let i = 0; i < normalAttribute.count; i++) {
            const x = normalAttribute.getX(i);
            const y = normalAttribute.getY(i);
            const z = normalAttribute.getZ(i);
            objContent += `vn ${x} ${y} ${z}\n`;
          }
        }
        
        // 添加面
        objContent += `\ng ${object.name || 'mesh'}\n`;
        objContent += `usemtl ${materialName}\n`;
        
        const index = geometry.index;
        if (index) {
          for (let i = 0; i < index.count; i += 3) {
            const a = index.getX(i) + 1 + vertexOffset;
            const b = index.getX(i + 1) + 1 + vertexOffset;
            const c = index.getX(i + 2) + 1 + vertexOffset;
            
            objContent += `f ${a}/${a}/${a} ${b}/${b}/${b} ${c}/${c}/${c}\n`;
          }
        }
        
        vertexOffset += positionAttribute.count;
        if (uvAttribute) uvOffset += uvAttribute.count;
        if (normalAttribute) normalOffset += normalAttribute.count;
      }
    });
    
    // 保存文件
    const objBlob = new Blob([objContent], { type: 'text/plain' });
    const mtlBlob = new Blob([mtlContent], { type: 'text/plain' });
    
    saveAs(objBlob, 'model.obj');
    saveAs(mtlBlob, 'model.mtl');
    
    return objContent;
  }
  
  /**
   * 清理资源
   */
  public dispose(): void {
    this.dracoLoader.dispose();
  }
}

8.2 场景序列化与反序列化

typescript

// utils/SceneSerializer.ts
import * as THREE from 'three';

export interface SerializedScene {
  metadata: {
    version: string;
    generator: string;
    createdAt: number;
    updatedAt: number;
  };
  scene: {
    background?: string | number;
    fog?: any;
    environment?: string;
  };
  objects: SerializedObject[];
  materials: SerializedMaterial[];
  textures: SerializedTexture[];
  cameras: SerializedCamera[];
  lights: SerializedLight[];
  animations: SerializedAnimation[];
}

export interface SerializedObject {
  id: string;
  name: string;
  type: string;
  uuid: string;
  parent?: string;
  children?: string[];
  position: [number, number, number];
  rotation: [number, number, number];
  scale: [number, number, number];
  visible: boolean;
  userData: Record<string, any>;
  geometry?: SerializedGeometry;
  material?: string;
  castShadow: boolean;
  receiveShadow: boolean;
}

export interface SerializedMaterial {
  id: string;
  name: string;
  type: string;
  uuid: string;
  color?: string;
  opacity?: number;
  transparent?: boolean;
  roughness?: number;
  metalness?: number;
  map?: string;
  normalMap?: string;
  roughnessMap?: string;
  metalnessMap?: string;
  aoMap?: string;
  emissiveMap?: string;
  side?: number;
  blending?: number;
  userData: Record<string, any>;
}

export class SceneSerializer {
  private textureCache = new Map<string, string>();
  
  /**
   * 序列化场景
   */
  public serialize(scene: THREE.Scene): SerializedScene {
    const serialized: SerializedScene = {
      metadata: {
        version: '1.0.0',
        generator: '3D-Editor',
        createdAt: Date.now(),
        updatedAt: Date.now()
      },
      scene: {
        background: scene.background ? this.serializeColor(scene.background) : undefined,
        fog: scene.fog ? this.serializeFog(scene.fog) : undefined
      },
      objects: [],
      materials: [],
      textures: [],
      cameras: [],
      lights: [],
      animations: []
    };
    
    // 收集所有对象
    const objectMap = new Map<string, THREE.Object3D>();
    const materialMap = new Map<string, THREE.Material>();
    
    scene.traverse((object) => {
      objectMap.set(object.uuid, object);
      
      if (object instanceof THREE.Mesh && object.material) {
        if (Array.isArray(object.material)) {
          object.material.forEach(mat => materialMap.set(mat.uuid, mat));
        } else {
          materialMap.set(object.material.uuid, object.material);
        }
      }
      
      if (object instanceof THREE.Camera) {
        serialized.cameras.push(this.serializeCamera(object));
      }
      
      if (object instanceof THREE.Light) {
        serialized.lights.push(this.serializeLight(object));
      }
    });
    
    // 序列化材质
    materialMap.forEach((material) => {
      serialized.materials.push(this.serializeMaterial(material));
    });
    
    // 序列化对象
    objectMap.forEach((object) => {
      if (object === scene) return; // 跳过场景本身
      
      serialized.objects.push(this.serializeObject(object, objectMap));
    });
    
    return serialized;
  }
  
  /**
   * 序列化单个对象
   */
  private serializeObject(
    object: THREE.Object3D,
    objectMap: Map<string, THREE.Object3D>
  ): SerializedObject {
    const serialized: SerializedObject = {
      id: object.uuid,
      name: object.name,
      type: object.type,
      uuid: object.uuid,
      position: [object.position.x, object.position.y, object.position.z],
      rotation: [
        object.rotation.x,
        object.rotation.y,
        object.rotation.z,
        object.rotation.order
      ],
      scale: [object.scale.x, object.scale.y, object.scale.z],
      visible: object.visible,
      userData: { ...object.userData },
      castShadow: (object as any).castShadow || false,
      receiveShadow: (object as any).receiveShadow || false
    };
    
    // 处理父子关系
    if (object.parent && object.parent !== objectMap.get(object.parent.uuid)) {
      serialized.parent = object.parent.uuid;
    }
    
    // 处理子对象
    if (object.children.length > 0) {
      serialized.children = object.children.map(child => child.uuid);
    }
    
    // 处理网格对象
    if (object instanceof THREE.Mesh) {
      if (object.geometry) {
        serialized.geometry = this.serializeGeometry(object.geometry);
      }
      
      if (object.material) {
        if (Array.isArray(object.material)) {
          // 多材质情况,这里简化处理,取第一个材质
          serialized.material = object.material[0].uuid;
        } else {
          serialized.material = object.material.uuid;
        }
      }
    }
    
    return serialized;
  }
  
  /**
   * 序列化几何体
   */
  private serializeGeometry(geometry: THREE.BufferGeometry): any {
    const attributes: Record<string, any> = {};
    
    for (const name in geometry.attributes) {
      const attribute = geometry.attributes[name];
      attributes[name] = {
        array: Array.from(attribute.array),
        itemSize: attribute.itemSize,
        normalized: attribute.normalized
      };
    }
    
    return {
      type: geometry.type,
      uuid: geometry.uuid,
      attributes,
      index: geometry.index ? Array.from(geometry.index.array) : null,
      boundingSphere: geometry.boundingSphere ? {
        center: geometry.boundingSphere.center.toArray(),
        radius: geometry.boundingSphere.radius
      } : null
    };
  }
  
  /**
   * 序列化材质
   */
  private serializeMaterial(material: THREE.Material): SerializedMaterial {
    const serialized: SerializedMaterial = {
      id: material.uuid,
      name: material.name,
      type: material.type,
      uuid: material.uuid,
      userData: { ...material.userData }
    };
    
    // 不同类型材质的特殊处理
    if (material instanceof THREE.MeshStandardMaterial) {
      serialized.color = this.serializeColor(material.color);
      serialized.opacity = material.opacity;
      serialized.transparent = material.transparent;
      serialized.roughness = material.roughness;
      serialized.metalness = material.metalness;
      
      if (material.map) {
        serialized.map = this.serializeTexture(material.map);
      }
      
      if (material.normalMap) {
        serialized.normalMap = this.serializeTexture(material.normalMap);
      }
      
      serialized.side = material.side;
      serialized.blending = material.blending;
    }
    
    return serialized;
  }
  
  /**
   * 序列化颜色
   */
  private serializeColor(color: THREE.Color | string | number): string {
    if (color instanceof THREE.Color) {
      return `#${color.getHexString()}`;
    }
    return String(color);
  }
  
  /**
   * 序列化纹理
   */
  private serializeTexture(texture: THREE.Texture): string {
    // 如果纹理来自图片,保存其源URL
    if ((texture as any).image?.src) {
      return (texture as any).image.src;
    }
    
    // 否则,将纹理转换为Base64
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    if (!ctx || !texture.image) {
      return '';
    }
    
    canvas.width = texture.image.width;
    canvas.height = texture.image.height;
    
    ctx.drawImage(texture.image, 0, 0);
    
    return canvas.toDataURL();
  }
  
  /**
   * 序列化相机
   */
  private serializeCamera(camera: THREE.Camera): any {
    if (camera instanceof THREE.PerspectiveCamera) {
      return {
        type: 'PerspectiveCamera',
        uuid: camera.uuid,
        fov: camera.fov,
        aspect: camera.aspect,
        near: camera.near,
        far: camera.far,
        position: camera.position.toArray(),
        rotation: camera.rotation.toArray()
      };
    }
    
    return {
      type: camera.type,
      uuid: camera.uuid,
      position: camera.position.toArray(),
      rotation: camera.rotation.toArray()
    };
  }
  
  /**
   * 序列化光源
   */
  private serializeLight(light: THREE.Light): any {
    const baseData = {
      uuid: light.uuid,
      type: light.type,
      color: this.serializeColor(light.color),
      intensity: light.intensity,
      position: light.position.toArray()
    };
    
    if (light instanceof THREE.DirectionalLight) {
      return {
        ...baseData,
        target: light.target.position.toArray(),
        shadow: light.shadow ? this.serializeShadow(light.shadow) : undefined
      };
    }
    
    if (light instanceof THREE.PointLight) {
      return {
        ...baseData,
        distance: light.distance,
        decay: light.decay
      };
    }
    
    return baseData;
  }
  
  /**
   * 序列化雾效
   */
  private serializeFog(fog: THREE.Fog): any {
    if (fog instanceof THREE.Fog) {
      return {
        type: 'Fog',
        color: this.serializeColor(fog.color),
        near: fog.near,
        far: fog.far
      };
    }
    
    return null;
  }
  
  /**
   * 序列化阴影
   */
  private serializeShadow(shadow: THREE.LightShadow): any {
    return {
      camera: this.serializeCamera(shadow.camera),
      bias: shadow.bias,
      radius: shadow.radius,
      mapSize: shadow.mapSize.toArray()
    };
  }
  
  /**
   * 反序列化场景
   */
  public deserialize(data: SerializedScene): THREE.Scene {
    const scene = new THREE.Scene();
    
    // 还原场景设置
    if (data.scene.background) {
      scene.background = new THREE.Color(data.scene.background);
    }
    
    // 创建材质映射
    const materialMap = new Map<string, THREE.Material>();
    data.materials.forEach((matData) => {
      const material = this.deserializeMaterial(matData);
      materialMap.set(matData.uuid, material);
    });
    
    // 创建对象映射
    const objectMap = new Map<string, THREE.Object3D>();
    
    // 首先创建所有对象
    data.objects.forEach((objData) => {
      const object = this.deserializeObject(objData, materialMap);
      objectMap.set(objData.uuid, object);
    });
    
    // 然后建立父子关系
    data.objects.forEach((objData) => {
      const object = objectMap.get(objData.uuid)!;
      
      if (objData.parent) {
        const parent = objectMap.get(objData.parent);
        if (parent) {
          parent.add(object);
        }
      }
    });
    
    // 将所有根对象添加到场景
    objectMap.forEach((object) => {
      if (!object.parent) {
        scene.add(object);
      }
    });
    
    return scene;
  }
  
  /**
   * 反序列化对象
   */
  private deserializeObject(
    data: SerializedObject,
    materialMap: Map<string, THREE.Material>
  ): THREE.Object3D {
    let object: THREE.Object3D;
    
    if (data.type === 'Mesh' && data.geometry) {
      const geometry = this.deserializeGeometry(data.geometry);
      const material = data.material ? materialMap.get(data.material) : undefined;
      
      if (material) {
        object = new THREE.Mesh(geometry, material);
      } else {
        object = new THREE.Mesh(
          geometry,
          new THREE.MeshStandardMaterial({ color: 0xcccccc })
        );
      }
      
      (object as THREE.Mesh).castShadow = data.castShadow;
      (object as THREE.Mesh).receiveShadow = data.receiveShadow;
    } else {
      object = new THREE.Object3D();
    }
    
    object.uuid = data.uuid;
    object.name = data.name;
    object.position.set(...data.position);
    object.rotation.set(...data.rotation);
    object.scale.set(...data.scale);
    object.visible = data.visible;
    object.userData = { ...data.userData };
    
    return object;
  }
  
  /**
   * 反序列化几何体
   */
  private deserializeGeometry(data: any): THREE.BufferGeometry {
    const geometry = new THREE.BufferGeometry();
    geometry.uuid = data.uuid;
    
    // 恢复属性
    for (const name in data.attributes) {
      const attrData = data.attributes[name];
      const array = new Float32Array(attrData.array);
      const attribute = new THREE.BufferAttribute(array, attrData.itemSize);
      attribute.normalized = attrData.normalized;
      geometry.setAttribute(name, attribute);
    }
    
    // 恢复索引
    if (data.index) {
      const indexArray = new Uint16Array(data.index);
      geometry.setIndex(new THREE.BufferAttribute(indexArray, 1));
    }
    
    // 计算边界框和边界球
    geometry.computeBoundingBox();
    geometry.computeBoundingSphere();
    
    return geometry;
  }
  
  /**
   * 反序列化材质
   */
  private deserializeMaterial(data: SerializedMaterial): THREE.Material {
    let material: THREE.Material;
    
    switch (data.type) {
      case 'MeshStandardMaterial':
        material = new THREE.MeshStandardMaterial({
          color: data.color ? new THREE.Color(data.color) : 0xffffff,
          opacity: data.opacity ?? 1,
          transparent: data.transparent ?? false,
          roughness: data.roughness ?? 0.7,
          metalness: data.metalness ?? 0.2,
          side: data.side ?? THREE.FrontSide,
          blending: data.blending ?? THREE.NormalBlending
        });
        break;
        
      default:
        material = new THREE.MeshStandardMaterial({
          color: 0xcccccc
        });
        break;
    }
    
    material.uuid = data.uuid;
    material.name = data.name;
    material.userData = { ...data.userData };
    
    return material;
  }
}

第九章:高级功能实现

9.1 动画编辑器与时间线

vue

<!-- components/AnimationEditor.vue -->
<template>
  <div class="animation-editor">
    <!-- 动画时间线 -->
    <div class="timeline-container">
      <div class="timeline-header">
        <button @click="togglePlay" class="play-btn">
          {{ isPlaying ? '暂停' : '播放' }}
        </button>
        <span class="time-display">
          {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
        </span>
        <input
          v-model="currentTime"
          type="range"
          :min="0"
          :max="duration"
          step="0.01"
          class="timeline-slider"
          @input="onTimeChange"
        />
        <input
          v-model.number="playbackSpeed"
          type="number"
          min="0.1"
          max="5"
          step="0.1"
          class="speed-input"
        />
        <span class="speed-label">倍速</span>
      </div>
      
      <!-- 关键帧轨道 -->
      <div class="tracks-container">
        <div
          v-for="(track, index) in animationTracks"
          :key="index"
          class="animation-track"
        >
          <div class="track-header">
            <span class="track-name">{{ track.name }}</span>
            <button
              @click="toggleTrackVisibility(index)"
              class="track-visibility-btn"
            >
              {{ track.visible ? '隐藏' : '显示' }}
            </button>
          </div>
          
          <div class="track-content">
            <!-- 关键帧显示 -->
            <div
              v-for="keyframe in track.keyframes"
              :key="keyframe.time"
              class="keyframe"
              :style="{ left: `${(keyframe.time / duration) * 100}%` }"
              @mousedown="onKeyframeMouseDown(keyframe, track, $event)"
            >
              <div class="keyframe-handle"></div>
            </div>
            
            <!-- 当前时间指示器 -->
            <div
              class="current-time-indicator"
              :style="{ left: `${(currentTime / duration) * 100}%` }"
            ></div>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 关键帧属性编辑器 -->
    <div v-if="selectedKeyframe" class="keyframe-editor">
      <h4>关键帧属性</h4>
      
      <div class="property-row">
        <label>时间</label>
        <input
          v-model.number="selectedKeyframe.time"
          type="number"
          min="0"
          :max="duration"
          step="0.01"
          @change="updateKeyframeTime"
        />
        <span>秒</span>
      </div>
      
      <!-- 位置属性 -->
      <div v-if="selectedKeyframe.position" class="property-group">
        <h5>位置</h5>
        <div class="property-row">
          <label>X</label>
          <input
            v-model.number="selectedKeyframe.position.x"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
        </div>
        <div class="property-row">
          <label>Y</label>
          <input
            v-model.number="selectedKeyframe.position.y"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
        </div>
        <div class="property-row">
          <label>Z</label>
          <input
            v-model.number="selectedKeyframe.position.z"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
        </div>
      </div>
      
      <!-- 旋转属性 -->
      <div v-if="selectedKeyframe.rotation" class="property-group">
        <h5>旋转</h5>
        <div class="property-row">
          <label>X</label>
          <input
            v-model.number="selectedKeyframe.rotation.x"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
          <span>弧度</span>
        </div>
        <div class="property-row">
          <label>Y</label>
          <input
            v-model.number="selectedKeyframe.rotation.y"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
          <span>弧度</span>
        </div>
        <div class="property-row">
          <label>Z</label>
          <input
            v-model.number="selectedKeyframe.rotation.z"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
          <span>弧度</span>
        </div>
      </div>
      
      <!-- 缩放属性 -->
      <div v-if="selectedKeyframe.scale" class="property-group">
        <h5>缩放</h5>
        <div class="property-row">
          <label>X</label>
          <input
            v-model.number="selectedKeyframe.scale.x"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
        </div>
        <div class="property-row">
          <label>Y</label>
          <input
            v-model.number="selectedKeyframe.scale.y"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
        </div>
        <div class="property-row">
          <label>Z</label>
          <input
            v-model.number="selectedKeyframe.scale.z"
            type="number"
            step="0.01"
            @change="updateKeyframe"
          />
        </div>
      </div>
      
      <!-- 动画曲线编辑器 -->
      <div class="curve-editor">
        <h5>动画曲线</h5>
        <canvas
          ref="curveCanvas"
          width="300"
          height="200"
          @mousedown="onCurveMouseDown"
        ></canvas>
        
        <div class="curve-controls">
          <select v-model="selectedCurveType" @change="updateCurveType">
            <option value="linear">线性</option>
            <option value="easeIn">缓入</option>
            <option value="easeOut">缓出</option>
            <option value="easeInOut">缓入缓出</option>
            <option value="custom">自定义</option>
          </select>
        </div>
      </div>
      
      <div class="keyframe-actions">
        <button @click="deleteKeyframe" class="btn-danger">
          删除关键帧
        </button>
        <button @click="addKeyframeAtCurrentTime" class="btn-primary">
          在当前时间添加关键帧
        </button>
      </div>
    </div>
    
    <!-- 动画列表 -->
    <div class="animation-list">
      <h4>动画列表</h4>
      
      <div class="list-container">
        <div
          v-for="(anim, index) in animations"
          :key="index"
          class="animation-item"
          :class="{ active: currentAnimation === anim }"
          @click="selectAnimation(anim)"
        >
          <span class="animation-name">{{ anim.name }}</span>
          <span class="animation-duration">{{ anim.duration.toFixed(2) }}s</span>
          
          <div class="animation-actions">
            <button @click.stop="editAnimation(anim)" class="btn-small">
              编辑
            </button>
            <button @click.stop="deleteAnimation(index)" class="btn-small btn-danger">
              删除
            </button>
          </div>
        </div>
      </div>
      
      <div class="animation-creation">
        <button @click="createNewAnimation" class="btn-primary">
          新建动画
        </button>
        <button @click="importAnimation" class="btn-secondary">
          导入动画
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import * as THREE from 'three';

interface AnimationTrack {
  name: string;
  objectId: string;
  property: string;
  keyframes: AnimationKeyframe[];
  visible: boolean;
  color: string;
}

interface AnimationKeyframe {
  time: number;
  value: any;
  position?: THREE.Vector3;
  rotation?: THREE.Euler;
  scale?: THREE.Vector3;
  interpolation: string;
  curvePoints?: number[];
}

interface AnimationData {
  name: string;
  duration: number;
  tracks: AnimationTrack[];
  loop: boolean;
  uuid: string;
}

const props = defineProps<{
  selectedObjects: any[];
  scene: THREE.Scene;
}>();

const emit = defineEmits<{
  'animation-update': [animation: any];
  'animation-play': [time: number];
  'animation-stop': [];
}>();

// 状态
const animations = ref<AnimationData[]>([]);
const currentAnimation = ref<AnimationData | null>(null);
const animationTracks = ref<AnimationTrack[]>([]);
const selectedKeyframe = ref<AnimationKeyframe | null>(null);
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(10);
const playbackSpeed = ref(1);
const selectedCurveType = ref('linear');

// 动画循环
let animationFrameId: number | null = null;
let lastTimestamp: number | null = null;

const startAnimationLoop = () => {
  if (animationFrameId) return;
  
  const animate = (timestamp: number) => {
    if (!lastTimestamp) lastTimestamp = timestamp;
    
    const delta = (timestamp - lastTimestamp) / 1000;
    lastTimestamp = timestamp;
    
    if (isPlaying.value) {
      // 更新当前时间
      currentTime.value += delta * playbackSpeed.value;
      
      // 循环处理
      if (currentTime.value > duration.value) {
        currentTime.value = currentTime.value % duration.value;
      }
      
      // 更新场景中的动画
      updateAnimation(currentTime.value);
      
      // 通知父组件
      emit('animation-play', currentTime.value);
    }
    
    animationFrameId = requestAnimationFrame(animate);
  };
  
  animationFrameId = requestAnimationFrame(animate);
};

const stopAnimationLoop = () => {
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId);
    animationFrameId = null;
  }
  lastTimestamp = null;
};

const togglePlay = () => {
  isPlaying.value = !isPlaying.value;
  
  if (isPlaying.value) {
    startAnimationLoop();
  } else {
    emit('animation-stop');
  }
};

const onTimeChange = () => {
  updateAnimation(currentTime.value);
  emit('animation-play', currentTime.value);
};

const updateAnimation = (time: number) => {
  if (!currentAnimation.value) return;
  
  currentAnimation.value.tracks.forEach(track => {
    if (!track.visible) return;
    
    const object = findObjectById(track.objectId);
    if (!object) return;
    
    // 获取当前时间的关键帧
    const keyframes = track.keyframes.sort((a, b) => a.time - b.time);
    let prevKeyframe: AnimationKeyframe | null = null;
    let nextKeyframe: AnimationKeyframe | null = null;
    
    for (let i = 0; i < keyframes.length; i++) {
      if (keyframes[i].time <= time) {
        prevKeyframe = keyframes[i];
      }
      if (keyframes[i].time >= time) {
        nextKeyframe = keyframes[i];
        break;
      }
    }
    
    if (!prevKeyframe || !nextKeyframe) {
      // 没有关键帧或只有单个关键帧
      if (prevKeyframe) {
        applyKeyframeToObject(object, prevKeyframe);
      }
      return;
    }
    
    // 计算插值
    const t = (time - prevKeyframe.time) / (nextKeyframe.time - prevKeyframe.time);
    const interpolatedValue = interpolateValues(
      prevKeyframe,
      nextKeyframe,
      t,
      track.property
    );
    
    // 应用插值结果
    applyValueToObject(object, track.property, interpolatedValue);
  });
};

const interpolateValues = (
  a: AnimationKeyframe,
  b: AnimationKeyframe,
  t: number,
  property: string
): any => {
  // 根据插值类型计算
  switch (a.interpolation) {
    case 'linear':
      return interpolateLinear(a, b, t, property);
    case 'easeIn':
      return interpolateEaseIn(a, b, t, property);
    case 'easeOut':
      return interpolateEaseOut(a, b, t, property);
    case 'easeInOut':
      return interpolateEaseInOut(a, b, t, property);
    case 'custom':
      return interpolateCustom(a, b, t, property);
    default:
      return interpolateLinear(a, b, t, property);
  }
};

const interpolateLinear = (
  a: AnimationKeyframe,
  b: AnimationKeyframe,
  t: number,
  property: string
): any => {
  if (property === 'position' && a.position && b.position) {
    return new THREE.Vector3().lerpVectors(a.position, b.position, t);
  }
  
  if (property === 'rotation' && a.rotation && b.rotation) {
    const quatA = new THREE.Quaternion().setFromEuler(a.rotation);
    const quatB = new THREE.Quaternion().setFromEuler(b.rotation);
    const resultQuat = new THREE.Quaternion();
    resultQuat.slerpQuaternions(quatA, quatB, t);
    
    const euler = new THREE.Euler();
    euler.setFromQuaternion(resultQuat, a.rotation.order);
    return euler;
  }
  
  if (property === 'scale' && a.scale && b.scale) {
    return new THREE.Vector3().lerpVectors(a.scale, b.scale, t);
  }
  
  // 数值插值
  if (typeof a.value === 'number' && typeof b.value === 'number') {
    return a.value + (b.value - a.value) * t;
  }
  
  return a.value;
};

const applyKeyframeToObject = (object: any, keyframe: AnimationKeyframe): void => {
  if (keyframe.position) {
    object.position.copy(keyframe.position);
  }
  
  if (keyframe.rotation) {
    object.rotation.copy(keyframe.rotation);
  }
  
  if (keyframe.scale) {
    object.scale.copy(keyframe.scale);
  }
};

const applyValueToObject = (object: any, property: string, value: any): void => {
  const propPath = property.split('.');
  let target = object;
  
  for (let i = 0; i < propPath.length - 1; i++) {
    target = target[propPath[i]];
    if (!target) return;
  }
  
  const lastProp = propPath[propPath.length - 1];
  
  if (value instanceof THREE.Vector3) {
    target[lastProp].copy(value);
  } else if (value instanceof THREE.Euler) {
    target[lastProp].copy(value);
  } else {
    target[lastProp] = value;
  }
};

const findObjectById = (id: string): THREE.Object3D | null => {
  let found = null;
  
  props.scene.traverse((object) => {
    if (object.uuid === id) {
      found = object;
    }
  });
  
  return found;
};

const selectAnimation = (animation: AnimationData) => {
  currentAnimation.value = animation;
  animationTracks.value = animation.tracks;
  duration.value = animation.duration;
};

const createNewAnimation = () => {
  const newAnim: AnimationData = {
    name: `动画_${animations.value.length + 1}`,
    duration: 10,
    tracks: [],
    loop: true,
    uuid: THREE.MathUtils.generateUUID()
  };
  
  animations.value.push(newAnim);
  selectAnimation(newAnim);
};

const addKeyframeAtCurrentTime = () => {
  if (!currentAnimation.value || props.selectedObjects.length === 0) return;
  
  props.selectedObjects.forEach(object => {
    const track = animationTracks.value.find(
      t => t.objectId === object.uuid && t.property === 'position'
    );
    
    if (!track) {
      // 创建新轨道
      const newTrack: AnimationTrack = {
        name: `${object.name}_位置`,
        objectId: object.uuid,
        property: 'position',
        keyframes: [],
        visible: true,
        color: `#${Math.floor(Math.random() * 16777215).toString(16)}`
      };
      
      animationTracks.value.push(newTrack);
    }
  });
  
  // 为所有选中的对象添加关键帧
  props.selectedObjects.forEach(object => {
    animationTracks.value.forEach(track => {
      if (track.objectId === object.uuid) {
        const keyframe: AnimationKeyframe = {
          time: currentTime.value,
          value: null,
          position: object.position.clone(),
          rotation: object.rotation.clone(),
          scale: object.scale.clone(),
          interpolation: 'linear'
        };
        
        track.keyframes.push(keyframe);
      }
    });
  });
};

const updateKeyframe = () => {
  if (!selectedKeyframe.value) return;
  
  emit('animation-update', currentAnimation.value);
};

const updateKeyframeTime = () => {
  if (!selectedKeyframe.value) return;
  
  // 确保时间在有效范围内
  selectedKeyframe.value.time = Math.max(0, Math.min(duration.value, selectedKeyframe.value.time));
  updateKeyframe();
};

const deleteKeyframe = () => {
  if (!selectedKeyframe.value) return;
  
  animationTracks.value.forEach(track => {
    const index = track.keyframes.findIndex(k => k === selectedKeyframe.value);
    if (index >= 0) {
      track.keyframes.splice(index, 1);
    }
  });
  
  selectedKeyframe.value = null;
  emit('animation-update', currentAnimation.value);
};

const formatTime = (time: number): string => {
  const minutes = Math.floor(time / 60);
  const seconds = Math.floor(time % 60);
  const milliseconds = Math.floor((time % 1) * 100);
  
  return `${minutes.toString().padStart(2, '0')}:${seconds
    .toString()
    .padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
};

// 键盘快捷键
const handleKeyDown = (event: KeyboardEvent) => {
  if (event.target instanceof HTMLInputElement) return;
  
  switch (event.key) {
    case ' ':
      event.preventDefault();
      togglePlay();
      break;
    case 'k':
      if (event.ctrlKey || event.metaKey) {
        event.preventDefault();
        addKeyframeAtCurrentTime();
      }
      break;
  }
};

onMounted(() => {
  document.addEventListener('keydown', handleKeyDown);
  startAnimationLoop();
});

onUnmounted(() => {
  document.removeEventListener('keydown', handleKeyDown);
  stopAnimationLoop();
});
</script>

<style scoped>
.animation-editor {
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #1e1e1e;
  color: #fff;
}

.timeline-container {
  background: #252526;
  border-bottom: 1px solid #333;
  padding: 12px;
}

.timeline-header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
}

.play-btn {
  padding: 6px 12px;
  background: #007acc;
  border: none;
  color: white;
  border-radius: 4px;
  cursor: pointer;
}

.time-display {
  font-family: monospace;
  font-size: 14px;
}

.timeline-slider {
  flex: 1;
}

.speed-input {
  width: 60px;
  background: #444;
  border: 1px solid #555;
  color: white;
  padding: 4px;
  border-radius: 4px;
}

.tracks-container {
  max-height: 200px;
  overflow-y: auto;
}

.animation-track {
  margin-bottom: 8px;
  border: 1px solid #444;
  border-radius: 4px;
  overflow: hidden;
}

.track-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 4px 8px;
  background: #333;
}

.track-content {
  position: relative;
  height: 24px;
  background: #2a2a2a;
}

.keyframe {
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  cursor: pointer;
}

.keyframe-handle {
  width: 12px;
  height: 12px;
  background: #ff6b6b;
  border-radius: 50%;
  border: 2px solid white;
}

.current-time-indicator {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  background: #007acc;
  pointer-events: none;
}

.keyframe-editor {
  padding: 12px;
  background: #252526;
  border-bottom: 1px solid #333;
}

.property-group {
  margin: 12px 0;
  padding: 8px;
  background: #2a2a2a;
  border-radius: 4px;
}

.property-row {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
}

.property-row label {
  width: 60px;
  font-size: 12px;
  color: #aaa;
}

.property-row input {
  flex: 1;
  background: #444;
  border: 1px solid #555;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  margin: 0 8px;
}

.curve-editor {
  margin-top: 16px;
  padding: 12px;
  background: #2a2a2a;
  border-radius: 4px;
}

.curve-editor canvas {
  width: 100%;
  height: 200px;
  background: #333;
  border-radius: 4px;
  margin-bottom: 8px;
}

.animation-list {
  flex: 1;
  padding: 12px;
  overflow-y: auto;
}

.animation-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px;
  margin-bottom: 4px;
  background: #2a2a2a;
  border-radius: 4px;
  cursor: pointer;
}

.animation-item:hover {
  background: #333;
}

.animation-item.active {
  background: #007acc;
}

.animation-creation {
  display: flex;
  gap: 8px;
  margin-top: 16px;
}

.btn-primary {
  padding: 8px 16px;
  background: #007acc;
  border: none;
  color: white;
  border-radius: 4px;
  cursor: pointer;
}

.btn-danger {
  padding: 8px 16px;
  background: #dc3545;
  border: none;
  color: white;
  border-radius: 4px;
  cursor: pointer;
}
</style>

第十章:性能优化与最佳实践

10.1 渲染性能优化

typescript

// utils/PerformanceOptimizer.ts
import * as THREE from 'three';

export class PerformanceOptimizer {
  private scene: THREE.Scene;
  private renderer: THREE.WebGLRenderer;
  
  // 性能监控
  private stats = {
    frameTime: 0,
    fps: 0,
    drawCalls: 0,
    triangles: 0,
    geometries: 0,
    textures: 0
  };
  
  // 优化设置
  private settings = {
    enableFrustumCulling: true,
    enableOcclusionCulling: false,
    enableLOD: true,
    enableInstancing: true,
    enableShadows: true,
    shadowQuality: 'medium' as 'low' | 'medium' | 'high',
    textureQuality: 'medium' as 'low' | 'medium' | 'high',
    maxLights: 8,
    enablePostProcessing: false
  };
  
  constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {
    this.scene = scene;
    this.renderer = renderer;
    
    this.initPerformanceMonitoring();
    this.applyOptimizationSettings();
  }
  
  /**
   * 初始化性能监控
   */
  private initPerformanceMonitoring(): void {
    // 使用Performance API监控帧率
    let frameCount = 0;
    let lastTime = performance.now();
    
    const updateStats = () => {
      frameCount++;
      const currentTime = performance.now();
      
      if (currentTime - lastTime >= 1000) {
        this.stats.fps = Math.round((frameCount * 1000) / (currentTime - lastTime));
        frameCount = 0;
        lastTime = currentTime;
        
        this.updatePerformanceStats();
      }
      
      requestAnimationFrame(updateStats);
    };
    
    updateStats();
  }
  
  /**
   * 更新性能统计
   */
  private updatePerformanceStats(): void {
    // 获取渲染器统计信息
    const info = this.renderer.info;
    
    this.stats.drawCalls = info.render.calls;
    this.stats.triangles = info.render.triangles;
    this.stats.geometries = info.memory.geometries;
    this.stats.textures = info.memory.textures;
    
    // 输出性能信息(开发环境)
    if (process.env.NODE_ENV === 'development') {
      console.log(`FPS: ${this.stats.fps}, Draw Calls: ${this.stats.drawCalls}, Triangles: ${this.stats.triangles}`);
    }
  }
  
  /**
   * 应用优化设置
   */
  private applyOptimizationSettings(): void {
    // 视锥体剔除
    if (this.settings.enableFrustumCulling) {
      this.scene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.frustumCulled = true;
        }
      });
    }
    
    // 阴影质量设置
    this.updateShadowQuality();
    
    // 纹理质量设置
    this.updateTextureQuality();
  }
  
  /**
   * 更新阴影质量
   */
  private updateShadowQuality(): void {
    const shadowMap = this.renderer.shadowMap;
    
    switch (this.settings.shadowQuality) {
      case 'low':
        shadowMap.type = THREE.BasicShadowMap;
        shadowMap.autoUpdate = false;
        break;
      case 'medium':
        shadowMap.type = THREE.PCFShadowMap;
        shadowMap.autoUpdate = true;
        break;
      case 'high':
        shadowMap.type = THREE.PCFSoftShadowMap;
        shadowMap.autoUpdate = true;
        break;
    }
  }
  
  /**
   * 更新纹理质量
   */
  private updateTextureQuality(): void {
    // 遍历所有材质,调整纹理质量
    this.scene.traverse((object) => {
      if (object instanceof THREE.Mesh && object.material) {
        const materials = Array.isArray(object.material) 
          ? object.material 
          : [object.material];
        
        materials.forEach((material) => {
          if (material.map) {
            material.map.anisotropy = this.settings.textureQuality === 'high' 
              ? this.renderer.capabilities.getMaxAnisotropy() 
              : 1;
          }
        });
      }
    });
  }
  
  /**
   * 应用LOD(细节层次)
   */
  public applyLOD(object: THREE.Object3D, distances: number[] = [50, 100, 200]): void {
    if (!this.settings.enableLOD || !(object instanceof THREE.Mesh)) {
      return;
    }
    
    const lod = new THREE.LOD();
    
    // 为不同距离创建不同细节级别的网格
    distances.forEach((distance, index) => {
      let geometry: THREE.BufferGeometry;
      
      if (index === 0) {
        // 最高细节级别
        geometry = object.geometry;
      } else {
        // 简化几何体
        geometry = this.simplifyGeometry(object.geometry, 1 - (index * 0.3));
      }
      
      const mesh = new THREE.Mesh(geometry, object.material);
      lod.addLevel(mesh, distance);
    });
    
    // 替换原对象
    object.parent?.add(lod);
    object.parent?.remove(object);
  }
  
  /**
   * 简化几何体
   */
  private simplifyGeometry(
    geometry: THREE.BufferGeometry,
    quality: number
  ): THREE.BufferGeometry {
    // 这里可以使用更复杂的简化算法
    // 简化示例:减少顶点数量
    const simplified = geometry.clone();
    
    if (simplified.index) {
      const oldIndices = simplified.index.array;
      const newIndices = [];
      
      // 简单的简化:每隔几个顶点取一个
      const step = Math.max(1, Math.floor(1 / quality));
      
      for (let i = 0; i < oldIndices.length; i += step) {
        newIndices.push(oldIndices[i]);
      }
      
      simplified.setIndex(newIndices);
    }
    
    return simplified;
  }
  
  /**
   * 应用实例化渲染
   */
  public applyInstancing(
    geometry: THREE.BufferGeometry,
    material: THREE.Material,
    count: number,
    positions: THREE.Vector3[] = []
  ): THREE.InstancedMesh {
    if (!this.settings.enableInstancing) {
      // 如果不支持实例化,则创建多个普通网格
      const group = new THREE.Group();
      
      for (let i = 0; i < count; i++) {
        const mesh = new THREE.Mesh(geometry, material);
        if (positions[i]) {
          mesh.position.copy(positions[i]);
        }
        group.add(mesh);
      }
      
      return group as any;
    }
    
    const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
    
    const matrix = new THREE.Matrix4();
    const position = new THREE.Vector3();
    const quaternion = new THREE.Quaternion();
    const scale = new THREE.Vector3(1, 1, 1);
    
    for (let i = 0; i < count; i++) {
      if (positions[i]) {
        position.copy(positions[i]);
      } else {
        position.set(
          (Math.random() - 0.5) * 100,
          (Math.random() - 0.5) * 100,
          (Math.random() - 0.5) * 100
        );
      }
      
      matrix.compose(position, quaternion, scale);
      instancedMesh.setMatrixAt(i, matrix);
    }
    
    instancedMesh.instanceMatrix.needsUpdate = true;
    
    return instancedMesh;
  }
  
  /**
   * 合并几何体(减少绘制调用)
   */
  public mergeGeometries(objects: THREE.Mesh[]): THREE.Mesh | null {
    if (objects.length === 0) return null;
    
    const geometries: THREE.BufferGeometry[] = [];
    const materials: THREE.Material[] = [];
    
    objects.forEach((object) => {
      geometries.push(object.geometry);
      
      if (Array.isArray(object.material)) {
        materials.push(...object.material);
      } else {
        materials.push(object.material);
      }
    });
    
    // 合并几何体
    const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(geometries);
    
    if (!mergedGeometry) {
      console.warn('几何体合并失败');
      return null;
    }
    
    // 创建合并后的网格
    const mergedMesh = new THREE.Mesh(mergedGeometry, materials[0]);
    
    // 从场景中移除原始对象
    objects.forEach((object) => {
      object.parent?.remove(object);
      object.geometry.dispose();
      
      if (object.material) {
        if (Array.isArray(object.material)) {
          object.material.forEach(mat => mat.dispose());
        } else {
          object.material.dispose();
        }
      }
    });
    
    return mergedMesh;
  }
  
  /**
   * 优化纹理内存
   */
  public optimizeTextures(): void {
    const textureCache = new Set<THREE.Texture>();
    
    this.scene.traverse((object) => {
      if (object instanceof THREE.Mesh && object.material) {
        const materials = Array.isArray(object.material)
          ? object.material
          : [object.material];
        
        materials.forEach((material) => {
          const textureProperties = [
            'map', 'normalMap', 'roughnessMap', 'metalnessMap',
            'aoMap', 'emissiveMap', 'alphaMap', 'bumpMap'
          ];
          
          textureProperties.forEach((prop) => {
            const texture = (material as any)[prop];
            if (texture && texture instanceof THREE.Texture) {
              if (textureCache.has(texture)) {
                // 重用纹理
                (material as any)[prop] = texture;
              } else {
                textureCache.add(texture);
              }
            }
          });
        });
      }
    });
  }
  
  /**
   * 动态调整渲染质量
   */
  public adjustQualityBasedOnFPS(): void {
    const targetFPS = 60;
    const fps = this.stats.fps;
    
    if (fps < 30) {
      // FPS过低,降低质量
      if (this.settings.shadowQuality !== 'low') {
        this.settings.shadowQuality = 'low';
        this.updateShadowQuality();
      }
      
      if (this.settings.textureQuality !== 'low') {
        this.settings.textureQuality = 'low';
        this.updateTextureQuality();
      }
      
      this.settings.enablePostProcessing = false;
    } else if (fps > 50) {
      // FPS足够,提高质量
      if (this.settings.shadowQuality !== 'high') {
        this.settings.shadowQuality = 'high';
        this.updateShadowQuality();
      }
      
      if (this.settings.textureQuality !== 'high') {
        this.settings.textureQuality = 'high';
        this.updateTextureQuality();
      }
      
      this.settings.enablePostProcessing = true;
    }
  }
  
  /**
   * 获取性能统计
   */
  public getStats() {
    return { ...this.stats };
  }
  
  /**
   * 获取优化设置
   */
  public getSettings() {
    return { ...this.settings };
  }
  
  /**
   * 更新优化设置
   */
  public updateSettings(newSettings: Partial<typeof this.settings>): void {
    Object.assign(this.settings, newSettings);
    this.applyOptimizationSettings();
  }
}

10.2 内存管理与资源清理

typescript

// utils/ResourceManager.ts
import * as THREE from 'three';

export class ResourceManager {
  private geometries = new Map<string, THREE.BufferGeometry>();
  private materials = new Map<string, THREE.Material>();
  private textures = new Map<string, THREE.Texture>();
  private objects = new Map<string, THREE.Object3D>();
  
  private cleanupInterval: number | null = null;
  private maxCacheSize = {
    geometries: 100,
    materials: 50,
    textures: 30
  };
  
  constructor() {
    // 定期清理未使用的资源
    this.startCleanupInterval();
  }
  
  /**
   * 注册几何体
   */
  public registerGeometry(
    id: string,
    geometry: THREE.BufferGeometry,
    autoDispose: boolean = true
  ): void {
    if (autoDispose) {
      geometry.userData.autoDispose = true;
      geometry.userData.lastUsed = Date.now();
      geometry.userData.referenceCount = 0;
    }
    
    this.geometries.set(id, geometry);
  }
  
  /**
   * 获取几何体
   */
  public getGeometry(id: string): THREE.BufferGeometry | undefined {
    const geometry = this.geometries.get(id);
    
    if (geometry && geometry.userData.autoDispose) {
      geometry.userData.lastUsed = Date.now();
      geometry.userData.referenceCount++;
    }
    
    return geometry;
  }
  
  /**
   * 注册材质
   */
  public registerMaterial(
    id: string,
    material: THREE.Material,
    autoDispose: boolean = true
  ): void {
    if (autoDispose) {
      material.userData.autoDispose = true;
      material.userData.lastUsed = Date.now();
      material.userData.referenceCount = 0;
    }
    
    this.materials.set(id, material);
  }
  
  /**
   * 获取材质
   */
  public getMaterial(id: string): THREE.Material | undefined {
    const material = this.materials.get(id);
    
    if (material && material.userData.autoDispose) {
      material.userData.lastUsed = Date.now();
      material.userData.referenceCount++;
    }
    
    return material;
  }
  
  /**
   * 注册纹理
   */
  public registerTexture(
    id: string,
    texture: THREE.Texture,
    autoDispose: boolean = true
  ): void {
    if (autoDispose) {
      texture.userData.autoDispose = true;
      texture.userData.lastUsed = Date.now();
      texture.userData.referenceCount = 0;
    }
    
    this.textures.set(id, texture);
  }
  
  /**
   * 获取纹理
   */
  public getTexture(id: string): THREE.Texture | undefined {
    const texture = this.textures.get(id);
    
    if (texture && texture.userData.autoDispose) {
      texture.userData.lastUsed = Date.now();
      texture.userData.referenceCount++;
    }
    
    return texture;
  }
  
  /**
   * 注册对象
   */
  public registerObject(id: string, object: THREE.Object3D): void {
    this.objects.set(id, object);
  }
  
  /**
   * 移除对象及其资源
   */
  public removeObject(id: string): boolean {
    const object = this.objects.get(id);
    if (!object) return false;
    
    // 清理对象及其子对象的资源
    this.disposeObject(object);
    
    // 从场景中移除
    object.parent?.remove(object);
    
    // 从管理器中移除
    this.objects.delete(id);
    
    return true;
  }
  
  /**
   * 清理对象资源
   */
  private disposeObject(object: THREE.Object3D): void {
    object.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        // 清理几何体
        if (child.geometry) {
          this.disposeGeometry(child.geometry);
        }
        
        // 清理材质
        if (child.material) {
          if (Array.isArray(child.material)) {
            child.material.forEach(mat => this.disposeMaterial(mat));
          } else {
            this.disposeMaterial(child.material);
          }
        }
      }
    });
  }
  
  /**
   * 清理几何体
   */
  private disposeGeometry(geometry: THREE.BufferGeometry): void {
    geometry.dispose();
    
    // 从缓存中移除
    this.geometries.forEach((value, key) => {
      if (value === geometry) {
        this.geometries.delete(key);
      }
    });
  }
  
  /**
   * 清理材质
   */
  private disposeMaterial(material: THREE.Material): void {
    // 清理材质使用的纹理
    const textureProperties = [
      'map', 'normalMap', 'roughnessMap', 'metalnessMap',
      'aoMap', 'emissiveMap', 'alphaMap', 'bumpMap'
    ];
    
    textureProperties.forEach((prop) => {
      const texture = (material as any)[prop];
      if (texture && texture instanceof THREE.Texture) {
        this.disposeTexture(texture);
      }
    });
    
    material.dispose();
    
    // 从缓存中移除
    this.materials.forEach((value, key) => {
      if (value === material) {
        this.materials.delete(key);
      }
    });
  }
  
  /**
   * 清理纹理
   */
  private disposeTexture(texture: THREE.Texture): void {
    texture.dispose();
    
    // 从缓存中移除
    this.textures.forEach((value, key) => {
      if (value === texture) {
        this.textures.delete(key);
      }
    });
  }
  
  /**
   * 开始定期清理
   */
  private startCleanupInterval(): void {
    this.cleanupInterval = window.setInterval(() => {
      this.cleanupUnusedResources();
    }, 30000); // 每30秒清理一次
  }
  
  /**
   * 清理未使用的资源
   */
  private cleanupUnusedResources(): void {
    const now = Date.now();
    const timeout = 60000; // 1分钟未使用则清理
    
    // 清理几何体
    this.cleanupResource(this.geometries, 'geometries', now, timeout);
    
    // 清理材质
    this.cleanupResource(this.materials, 'materials', now, timeout);
    
    // 清理纹理
    this.cleanupResource(this.textures, 'textures', now, timeout);
  }
  
  /**
   * 清理特定类型的资源
   */
  private cleanupResource<T>(
    map: Map<string, T & { userData?: any }>,
    type: keyof typeof this.maxCacheSize,
    now: number,
    timeout: number
  ): void {
    const maxSize = this.maxCacheSize[type];
    
    // 如果缓存超过最大大小,清理最旧的资源
    if (map.size > maxSize) {
      const entries = Array.from(map.entries());
      
      // 按最后使用时间排序
      entries.sort((a, b) => {
        const timeA = a[1].userData?.lastUsed || 0;
        const timeB = b[1].userData?.lastUsed || 0;
        return timeA - timeB;
      });
      
      // 清理超出的部分
      const toRemove = entries.slice(0, entries.length - maxSize);
      
      toRemove.forEach(([key, resource]) => {
        if (this.canDisposeResource(resource)) {
          this.disposeResource(resource, type);
          map.delete(key);
        }
      });
    } else {
      // 清理超时未使用的资源
      map.forEach((resource, key) => {
        if (this.canDisposeResource(resource)) {
          const lastUsed = resource.userData?.lastUsed || 0;
          
          if (now - lastUsed > timeout) {
            this.disposeResource(resource, type);
            map.delete(key);
          }
        }
      });
    }
  }
  
  /**
   * 检查资源是否可以清理
   */
  private canDisposeResource(resource: any): boolean {
    return resource.userData?.autoDispose && 
           (resource.userData.referenceCount === 0 || 
            (resource.userData.referenceCount === undefined));
  }
  
  /**
   * 清理资源
   */
  private disposeResource(resource: any, type: string): void {
    switch (type) {
      case 'geometries':
        if (resource.dispose) resource.dispose();
        break;
      case 'materials':
        if (resource.dispose) resource.dispose();
        break;
      case 'textures':
        if (resource.dispose) resource.dispose();
        break;
    }
  }
  
  /**
   * 获取资源统计
   */
  public getResourceStats(): {
    geometries: number;
    materials: number;
    textures: number;
    objects: number;
  } {
    return {
      geometries: this.geometries.size,
      materials: this.materials.size,
      textures: this.textures.size,
      objects: this.objects.size
    };
  }
  
  /**
   * 清理所有资源
   */
  public dispose(): void {
    // 停止清理定时器
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }
    
    // 清理所有资源
    this.geometries.forEach(geometry => geometry.dispose());
    this.materials.forEach(material => material.dispose());
    this.textures.forEach(texture => texture.dispose());
    
    this.geometries.clear();
    this.materials.clear();
    this.textures.clear();
    this.objects.clear();
  }
}

第十一章:部署与优化

11.1 生产环境构建配置

javascript

// vue.config.js
const { defineConfig } = require('@vue/cli-service');
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = defineConfig({
  transpileDependencies: true,
  
  // 生产环境配置
  productionSourceMap: false,
  
  // 配置Webpack
  configureWebpack: (config) => {
    if (process.env.NODE_ENV === 'production') {
      // 生产环境配置
      config.optimization = {
        splitChunks: {
          chunks: 'all',
          cacheGroups: {
            three: {
              test: /[\\/]node_modules[\\/]three[\\/]/,
              name: 'three',
              priority: 10,
              chunks: 'all'
            },
            vendors: {
              test: /[\\/]node_modules[\\/]/,
              name: 'vendors',
              priority: 5,
              chunks: 'all'
            }
          }
        },
        minimize: true
      };
      
      // 添加压缩插件
      config.plugins.push(
        new CompressionPlugin({
          algorithm: 'gzip',
          test: /\.(js|css|html|svg|json)$/,
          threshold: 10240,
          minRatio: 0.8
        })
      );
    } else {
      // 开发环境配置
      config.devtool = 'source-map';
    }
    
    // 配置性能提示
    config.performance = {
      hints: process.env.NODE_ENV === 'production' ? 'warning' : false,
      maxAssetSize: 1024 * 1024 * 5, // 5MB
      maxEntrypointSize: 1024 * 1024 * 5 // 5MB
    };
    
    return config;
  },
  
  // 配置开发服务器
  devServer: {
    hot: true,
    open: true,
    port: 8080,
    client: {
      overlay: {
        warnings: false,
        errors: true
      }
    },
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp'
    }
  },
  
  // 配置PWA(可选)
  pwa: {
    name: '3D Editor',
    themeColor: '#1e1e1e',
    msTileColor: '#1e1e1e',
    appleMobileWebAppCapable: 'yes',
    appleMobileWebAppStatusBarStyle: 'black',
    
    // 配置manifest
    manifestOptions: {
      name: '3D Editor',
      short_name: '3D Editor',
      start_url: '.',
      display: 'standalone',
      theme_color: '#1e1e1e',
      background_color: '#1e1e1e',
      icons: [
        {
          src: './img/icons/android-chrome-192x192.png',
          sizes: '192x192',
          type: 'image/png'
        },
        {
          src: './img/icons/android-chrome-512x512.png',
          sizes: '512x512',
          type: 'image/png'
        }
      ]
    },
    
    // 配置workbox
    workboxPluginMode: 'InjectManifest',
    workboxOptions: {
      swSrc: './src/service-worker.js',
      exclude: [/\.map$/, /manifest\.json$/]
    }
  }
});

11.2 Service Worker配置

javascript

// src/service-worker.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 预缓存主应用资源
precacheAndRoute(self.__WB_MANIFEST);

// 缓存Three.js库
registerRoute(
  /https:\/\/unpkg\.com\/three@.*/,
  new CacheFirst({
    cacheName: 'three-js-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200]
      }),
      new ExpirationPlugin({
        maxEntries: 10,
        maxAgeSeconds: 60 * 60 * 24 * 30 // 30天
      })
    ]
  })
);

// 缓存模型资源
registerRoute(
  /\.(glb|gltf|obj|mtl|fbx)$/,
  new CacheFirst({
    cacheName: 'model-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200]
      }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 60 * 60 * 24 * 7 // 7天
      })
    ]
  })
);

// 缓存纹理资源
registerRoute(
  /\.(jpg|jpeg|png|webp|gif|svg)$/,
  new CacheFirst({
    cacheName: 'texture-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200]
      }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 * 24 * 30 // 30天
      })
    ]
  })
);

// 缓存API请求
registerRoute(
  /\/api\/.*/,
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [200]
      }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 * 1 // 1小时
      })
    ]
  })
);

// 离线页面
registerRoute(
  ({ request }) => request.mode === 'navigate',
  async ({ event }) => {
    try {
      return await fetch(event.request);
    } catch (error) {
      return caches.match('/offline.html');
    }
  }
);

// 安装事件
self.addEventListener('install', (event) => {
  console.log('Service Worker 安装成功');
  self.skipWaiting();
});

// 激活事件
self.addEventListener('activate', (event) => {
  console.log('Service Worker 激活成功');
  event.waitUntil(self.clients.claim());
});

// 消息处理
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

第十二章:总结与扩展

12.1 项目总结

通过本教程,我们完成了以下核心功能:

  1. 基础3D场景搭建:使用Three.js创建了完整的3D场景

  2. 对象管理系统:实现了3D对象的增删改查、选择和高亮

  3. 材质编辑器:创建了可视化的材质编辑界面

  4. 纹理管理系统:实现了纹理的加载、管理和优化

  5. 动画系统:构建了关键帧动画编辑器

  6. 导入导出:支持GLTF/GLB/OBJ等格式的导入导出

  7. 性能优化:实现了多层次的性能优化方案

  8. 生产部署:配置了完整的生产环境构建流程

12.2 扩展建议

  1. 物理引擎集成:集成Cannon.js或Ammo.js实现物理模拟

  2. 多人协作:使用WebSocket实现实时协作编辑

  3. VR/AR支持:集成WebXR API支持虚拟现实和增强现实

  4. 自定义着色器:添加GLSL着色器编辑器

  5. 插件系统:设计插件架构,支持第三方扩展

  6. 云存储集成:集成云存储服务,实现项目云端同步

12.3 学习资源推荐

  1. Three.js官方文档https://threejs.org/docs/

  2. Three.js示例https://threejs.org/examples/

  3. Vue3官方文档https://vuejs.org/

  4. WebGL基础https://webglfundamentals.org/

  5. GLTF规范https://www.khronos.org/gltf/

12.4 常见问题与解决方案

  1. 性能问题:使用性能监控工具,合理设置LOD和实例化

  2. 内存泄漏:定期清理未使用的资源,使用ResourceManager

  3. 兼容性问题:使用特性检测,提供降级方案

  4. 大文件处理:使用分块加载和进度提示

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐