Vue3 + Three.js 3D编辑器开发
第一章:引言与项目概述
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编辑器,主要包括:
-
基础3D场景管理
-
模型导入与导出
-
场景编辑功能(选择、移动、旋转、缩放)
-
材质与纹理编辑
-
光照系统配置
-
动画编辑与时间线
-
场景序列化与反序列化
-
插件系统与扩展性
第二章:项目架构设计与初始化
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核心概念详解
三大核心组件:
-
场景(Scene):3D对象的容器
-
相机(Camera):观察场景的视角
-
渲染器(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 项目总结
通过本教程,我们完成了以下核心功能:
-
基础3D场景搭建:使用Three.js创建了完整的3D场景
-
对象管理系统:实现了3D对象的增删改查、选择和高亮
-
材质编辑器:创建了可视化的材质编辑界面
-
纹理管理系统:实现了纹理的加载、管理和优化
-
动画系统:构建了关键帧动画编辑器
-
导入导出:支持GLTF/GLB/OBJ等格式的导入导出
-
性能优化:实现了多层次的性能优化方案
-
生产部署:配置了完整的生产环境构建流程
12.2 扩展建议
-
物理引擎集成:集成Cannon.js或Ammo.js实现物理模拟
-
多人协作:使用WebSocket实现实时协作编辑
-
VR/AR支持:集成WebXR API支持虚拟现实和增强现实
-
自定义着色器:添加GLSL着色器编辑器
-
插件系统:设计插件架构,支持第三方扩展
-
云存储集成:集成云存储服务,实现项目云端同步
12.3 学习资源推荐
-
Three.js官方文档:https://threejs.org/docs/
-
Three.js示例:https://threejs.org/examples/
-
Vue3官方文档:https://vuejs.org/
-
WebGL基础:https://webglfundamentals.org/
12.4 常见问题与解决方案
-
性能问题:使用性能监控工具,合理设置LOD和实例化
-
内存泄漏:定期清理未使用的资源,使用ResourceManager
-
兼容性问题:使用特性检测,提供降级方案
-
大文件处理:使用分块加载和进度提示
更多推荐


所有评论(0)