返回笔记首页

Three.js 基础架构完整指南

主题配置

一、场景搭建与渲染循环

1.1 简历描述模板

项目经验:3D 产品展示平台 - Three.js 架构设计

负责搭建 3D 产品展示平台的 Three.js 基础架构,实现了场景管理、渲染优化、交互控制等核心功能。通过合理的架构设计和性能优化,支持同时展示 20+ 高精度 3D 模型,帧率稳定在 60fps。核心工作包括:

  • 设计了模块化的场景管理架构,实现场景的创建、销毁、切换和资源回收机制
  • 优化了渲染循环,通过按需渲染和智能更新策略,降低 GPU 负载 40%
  • 实现了多相机系统,支持透视相机和正交相机的无缝切换,适配不同展示需求
  • 封装了灯光预设系统,提供 10+ 常用光照方案,业务方可快速配置场景氛围
  • 集成了性能监控面板,实时追踪 FPS、渲染时间、内存占用等关键指标

1.2 SOP 标准回答

面试官:你是如何搭建 Three.js 项目架构的?

我们做 3D 产品展示平台时,需要支持各种产品的 3D 展示,包括家具、电器、汽车等。这些模型都很大,有的甚至上百 MB,对性能要求很高。我设计了一套模块化的架构来解决这些问题。

首先是场景管理。我把场景抽象成一个独立的类,封装了场景的初始化、更新、销毁等生命周期。每个场景都有自己的资源管理器,负责跟踪场景中的所有对象,确保在切换场景时能完全清理资源,避免内存泄漏。

然后是渲染循环的优化。我没有用传统的 requestAnimationFrame 无限循环渲染,而是实现了一个智能渲染系统。只有当场景中有物体移动、相机变化或者用户交互时才会触发渲染。静态场景就不再重复渲染,这样能节省很多 GPU 资源。我还加了一个脏标记系统,记录哪些对象发生了变化,只更新变化的部分。

相机方面,我封装了一个相机管理器。支持多个相机预设位置,用户可以一键切换视角。比如看汽车模型时,可以快速切换到外观视角、内饰视角、引擎细节等。切换时有平滑的动画过渡,用 Tween.js 做插值,体验很流畅。

还有一个关键点是资源加载。3D 模型文件很大,不能一次性全加载,会卡很久。我实现了一个资源队列加载器,根据优先级分批加载。先加载低精度的预览模型,用户能快速看到效果,然后在后台逐步加载高精度模型并替换。这样首屏加载时间从 10 秒降到了 2 秒。

性能监控也很重要。我集成了 Stats.js,可以实时看到 FPS、渲染时间等指标。在开发阶段帮我发现了很多性能问题,比如某些材质的 shader 太复杂、灯光数量过多等。线上版本会把这些数据上报到监控平台,方便排查用户端的性能问题。

面试官:渲染循环是如何优化的?

渲染循环的优化我主要从三个方面入手。

第一是按需渲染。传统的做法是用 requestAnimationFrame 不停地渲染,即使画面没有任何变化也在渲染,很浪费性能。我的做法是维护一个渲染队列,只有当场景状态发生变化时才把渲染任务加入队列。什么算状态变化呢?比如相机移动、物体动画、光照变化、用户交互等。静态场景就直接用上一帧的结果,不再重新渲染。

第二是分层渲染。我把场景分成了背景层、主体层、UI 层。背景层(比如天空盒)基本不变化,可以降低渲染频率;主体层就是主要的 3D 模型,需要高频渲染;UI 层是 2D 的交互元素,可以单独渲染。这样能减少不必要的渲染开销。

第三是渲染批次优化。我对场景中的对象做了合并处理,相同材质的物体会合并成一个 Mesh,减少 draw call。还用了 InstancedMesh 来渲染大量重复的物体,比如展示一百个相同的螺丝钉,用 InstancedMesh 只需要一次 draw call。

另外我还做了一些细节优化。比如视锥体剔除,不在相机视野内的物体不渲染;LOD(Level of Detail),根据物体到相机的距离动态调整模型精度;还有对象池,避免频繁创建销毁对象。

这些优化做完后,在中端手机上也能流畅运行,帧率基本稳定在 50fps 以上。

1.3 难点与亮点分析

难点 1:内存管理与资源回收

问题:Three.js 的资源(Geometry、Material、Texture)如果不手动释放,会导致严重的内存泄漏。

解决方案:

  • 实现了资源引用计数系统,追踪每个资源的使用情况
  • 封装了统一的资源释放方法,递归清理所有子对象
  • 在场景切换时自动触发资源回收,确保不留垃圾
  • 使用 WeakMap 存储资源映射,避免循环引用

技术亮点:

  • 资源池化机制,常用资源复用而非销毁
  • 延迟清理策略,避免频繁创建销毁导致的卡顿
  • 内存监控工具,实时显示内存使用情况
难点 2:渲染性能优化

问题:大场景、多模型情况下帧率下降严重,用户体验差。

解决方案:

  • 实现了智能渲染系统,只在必要时触发渲染
  • 使用视锥体剔除和遮挡剔除,减少不可见物体的渲染
  • 实现了 LOD 系统,根据距离动态调整模型精度
  • 合并相同材质的 Mesh,减少 draw call

技术亮点:

  • 自适应渲染质量,根据设备性能动态调整
  • GPU Instancing 优化批量渲染
  • 延迟渲染管线,提升复杂场景性能
难点 3:多场景管理

问题:需要支持多个独立场景的创建、切换、销毁,资源管理复杂。

解决方案:

  • 设计了场景生命周期管理系统
  • 实现了场景堆栈,支持场景的前进、后退、叠加
  • 场景间资源隔离,避免相互影响
  • 场景切换时的平滑过渡动画

技术亮点:

  • 场景预加载机制,提前准备下一个场景
  • 场景状态持久化,支持场景快照和恢复
  • 热更新支持,场景内容可动态替换

1.4 完整技术实现

基础场景管理器

vue
<!-- ThreeScene.vue -->
<template>
  <div class="three-scene">
    <div ref="containerRef" class="scene-container"></div>

    <!-- 性能监控面板 -->
    <div v-if="showStats" class="stats-panel">
      <div class="stat-item">
        <span>FPS:</span>
        <strong>{{ stats.fps }}</strong>
      </div>
      <div class="stat-item">
        <span>渲染时间:</span>
        <strong>{{ stats.renderTime }}ms</strong>
      </div>
      <div class="stat-item">
        <span>三角形数:</span>
        <strong>{{ stats.triangles }}</strong>
      </div>
      <div class="stat-item">
        <span>内存:</span>
        <strong>{{ stats.memory }}MB</strong>
      </div>
    </div>

    <!-- 控制面板 -->
    <div class="controls-panel">
      <button @click="resetCamera">重置相机</button>
      <button @click="toggleAutoRotate">
        {{ autoRotate ? '停止旋转' : '自动旋转' }}
      </button>
      <button @click="toggleWireframe">线框模式</button>
      <button @click="captureScreenshot">截图</button>
    </div>

    <!-- 加载进度 -->
    <div v-if="loading" class="loading-overlay">
      <div class="loading-spinner"></div>
      <p>加载中... {{ loadingProgress }}%</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import Stats from 'three/examples/jsm/libs/stats.module'

const props = defineProps({
  showStats: {
    type: Boolean,
    default: true
  },
  enableControls: {
    type: Boolean,
    default: true
  },
  backgroundColor: {
    type: String,
    default: '#1a1a1a'
  }
})

const emit = defineEmits(['ready', 'error'])

// DOM 引用
const containerRef = ref(null)

// Three.js 核心对象
let scene = null
let camera = null
let renderer = null
let controls = null
let animationId = null

// 性能监控
let statsMonitor = null
const stats = ref({
  fps: 0,
  renderTime: 0,
  triangles: 0,
  memory: 0
})

// 状态
const loading = ref(false)
const loadingProgress = ref(0)
const autoRotate = ref(false)
const wireframeMode = ref(false)

// 渲染控制
let needsRender = true
let lastFrameTime = 0
const targetFPS = 60
const frameInterval = 1000 / targetFPS

// 资源管理
const resources = new Map()
const disposables = new Set()

// 初始化场景
const initScene = () => {
  // 创建场景
  scene = new THREE.Scene()
  scene.background = new THREE.Color(props.backgroundColor)

  // 添加雾效
  scene.fog = new THREE.Fog(props.backgroundColor, 10, 50)

  return scene
}

// 初始化相机
const initCamera = () => {
  const width = containerRef.value.clientWidth
  const height = containerRef.value.clientHeight
  const aspect = width / height

  // 透视相机
  camera = new THREE.PerspectiveCamera(
    75,  // fov
    aspect,
    0.1, // near
    1000 // far
  )

  camera.position.set(5, 5, 5)
  camera.lookAt(0, 0, 0)

  return camera
}

// 初始化渲染器
const initRenderer = () => {
  const width = containerRef.value.clientWidth
  const height = containerRef.value.clientHeight

  renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    powerPreference: 'high-performance'
  })

  renderer.setSize(width, height)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

  // 启用阴影
  renderer.shadowMap.enabled = true
  renderer.shadowMap.type = THREE.PCFSoftShadowMap

  // 色调映射
  renderer.toneMapping = THREE.ACESFilmicToneMapping
  renderer.toneMappingExposure = 1

  // 输出编码
  renderer.outputEncoding = THREE.sRGBEncoding

  containerRef.value.appendChild(renderer.domElement)

  return renderer
}

// 初始化控制器
const initControls = () => {
  if (!props.enableControls) return null

  controls = new OrbitControls(camera, renderer.domElement)

  // 控制器配置
  controls.enableDamping = true
  controls.dampingFactor = 0.05
  controls.minDistance = 2
  controls.maxDistance = 20
  controls.maxPolarAngle = Math.PI / 2

  // 监听控制器变化
  controls.addEventListener('change', () => {
    needsRender = true
  })

  return controls
}

// 初始化光照
const initLights = () => {
  // 环境光
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
  scene.add(ambientLight)
  disposables.add(ambientLight)

  // 主光源(平行光)
  const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
  directionalLight.position.set(5, 10, 7.5)
  directionalLight.castShadow = true

  // 阴影配置
  directionalLight.shadow.camera.left = -10
  directionalLight.shadow.camera.right = 10
  directionalLight.shadow.camera.top = 10
  directionalLight.shadow.camera.bottom = -10
  directionalLight.shadow.camera.near = 0.1
  directionalLight.shadow.camera.far = 50
  directionalLight.shadow.mapSize.width = 2048
  directionalLight.shadow.mapSize.height = 2048

  scene.add(directionalLight)
  disposables.add(directionalLight)

  // 补光(半球光)
  const hemisphereLight = new THREE.HemisphereLight(
    0xffffff, // sky color
    0x444444, // ground color
    0.6
  )
  scene.add(hemisphereLight)
  disposables.add(hemisphereLight)

  // 点光源(可选)
  const pointLight = new THREE.PointLight(0xff9000, 0.5, 10)
  pointLight.position.set(-5, 5, 5)
  scene.add(pointLight)
  disposables.add(pointLight)
}

// 添加辅助工具
const addHelpers = () => {
  // 坐标轴辅助
  const axesHelper = new THREE.AxesHelper(5)
  scene.add(axesHelper)
  disposables.add(axesHelper)

  // 网格辅助
  const gridHelper = new THREE.GridHelper(20, 20)
  scene.add(gridHelper)
  disposables.add(gridHelper)
}

// 添加示例对象
const addDemoObjects = () => {
  // 地面
  const groundGeometry = new THREE.PlaneGeometry(20, 20)
  const groundMaterial = new THREE.MeshStandardMaterial({
    color: 0x808080,
    roughness: 0.8,
    metalness: 0.2
  })
  const ground = new THREE.Mesh(groundGeometry, groundMaterial)
  ground.rotation.x = -Math.PI / 2
  ground.receiveShadow = true
  scene.add(ground)
  disposables.add(ground)

  // 立方体
  const cubeGeometry = new THREE.BoxGeometry(1, 1, 1)
  const cubeMaterial = new THREE.MeshStandardMaterial({
    color: 0x00ff00,
    roughness: 0.5,
    metalness: 0.5
  })
  const cube = new THREE.Mesh(cubeGeometry, cubeMaterial)
  cube.position.set(-2, 0.5, 0)
  cube.castShadow = true
  cube.receiveShadow = true
  scene.add(cube)
  disposables.add(cube)

  // 球体
  const sphereGeometry = new THREE.SphereGeometry(0.5, 32, 32)
  const sphereMaterial = new THREE.MeshStandardMaterial({
    color: 0xff0000,
    roughness: 0.3,
    metalness: 0.7
  })
  const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
  sphere.position.set(0, 0.5, 0)
  sphere.castShadow = true
  sphere.receiveShadow = true
  scene.add(sphere)
  disposables.add(sphere)

  // 圆环
  const torusGeometry = new THREE.TorusGeometry(0.4, 0.15, 16, 100)
  const torusMaterial = new THREE.MeshStandardMaterial({
    color: 0x0000ff,
    roughness: 0.4,
    metalness: 0.6
  })
  const torus = new THREE.Mesh(torusGeometry, torusMaterial)
  torus.position.set(2, 0.5, 0)
  torus.castShadow = true
  torus.receiveShadow = true
  scene.add(torus)
  disposables.add(torus)

  // 保存引用以便动画
  resources.set('cube', cube)
  resources.set('sphere', sphere)
  resources.set('torus', torus)
}

// 初始化性能监控
const initStats = () => {
  if (!props.showStats) return

  statsMonitor = new Stats()
  statsMonitor.showPanel(0) // 0: fps, 1: ms, 2: mb
  statsMonitor.dom.style.position = 'absolute'
  statsMonitor.dom.style.top = '80px'
  statsMonitor.dom.style.left = '10px'
  containerRef.value.appendChild(statsMonitor.dom)
}

// 更新性能统计
const updateStats = () => {
  if (!statsMonitor) return

  statsMonitor.update()

  // 更新显示数据
  stats.value.fps = Math.round(1000 / (performance.now() - lastFrameTime))
  stats.value.triangles = renderer.info.render.triangles
  stats.value.memory = Math.round(
    (performance.memory?.usedJSHeapSize || 0) / 1048576
  )
}

// 渲染循环
const animate = (currentTime) => {
  animationId = requestAnimationFrame(animate)

  // 帧率控制
  const deltaTime = currentTime - lastFrameTime
  if (deltaTime < frameInterval) return

  lastFrameTime = currentTime - (deltaTime % frameInterval)

  // 更新控制器
  if (controls) {
    controls.update()
  }

  // 更新动画
  updateAnimations()

  // 只在需要时渲染
  if (needsRender) {
    const startTime = performance.now()

    renderer.render(scene, camera)

    stats.value.renderTime = Math.round(performance.now() - startTime)
    needsRender = false
  }

  // 更新性能监控
  updateStats()
}

// 更新动画
const updateAnimations = () => {
  const cube = resources.get('cube')
  const sphere = resources.get('sphere')
  const torus = resources.get('torus')

  if (autoRotate.value) {
    if (cube) cube.rotation.y += 0.01
    if (sphere) sphere.rotation.y += 0.01
    if (torus) {
      torus.rotation.x += 0.01
      torus.rotation.y += 0.01
    }
    needsRender = true
  }
}

// 处理窗口大小变化
const handleResize = () => {
  if (!containerRef.value) return

  const width = containerRef.value.clientWidth
  const height = containerRef.value.clientHeight

  // 更新相机
  camera.aspect = width / height
  camera.updateProjectionMatrix()

  // 更新渲染器
  renderer.setSize(width, height)

  needsRender = true
}

// 重置相机
const resetCamera = () => {
  if (!camera || !controls) return

  // 动画过渡到默认位置
  const targetPosition = new THREE.Vector3(5, 5, 5)
  const targetLookAt = new THREE.Vector3(0, 0, 0)

  animateCameraTransition(
    camera.position,
    targetPosition,
    controls.target,
    targetLookAt,
    1000
  )
}

// 相机平滑过渡
const animateCameraTransition = (
  startPos,
  endPos,
  startLookAt,
  endLookAt,
  duration
) => {
  const startTime = performance.now()

  const animate = () => {
    const elapsed = performance.now() - startTime
    const progress = Math.min(elapsed / duration, 1)

    // 使用缓动函数
    const eased = easeInOutCubic(progress)

    // 插值位置
    camera.position.lerpVectors(startPos, endPos, eased)
    controls.target.lerpVectors(startLookAt, endLookAt, eased)

    needsRender = true

    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  animate()
}

// 缓动函数
const easeInOutCubic = (t) => {
  return t < 0.5
    ? 4 * t * t * t
    : 1 - Math.pow(-2 * t + 2, 3) / 2
}

// 切换自动旋转
const toggleAutoRotate = () => {
  autoRotate.value = !autoRotate.value
  needsRender = true
}

// 切换线框模式
const toggleWireframe = () => {
  wireframeMode.value = !wireframeMode.value

  scene.traverse((object) => {
    if (object.isMesh && object.material) {
      object.material.wireframe = wireframeMode.value
      needsRender = true
    }
  })
}

// 截图
const captureScreenshot = () => {
  needsRender = true

  // 确保渲染一次
  renderer.render(scene, camera)

  // 获取图片数据
  const dataURL = renderer.domElement.toDataURL('image/png')

  // 下载图片
  const link = document.createElement('a')
  link.download = `screenshot-${Date.now()}.png`
  link.href = dataURL
  link.click()
}

// 清理资源
const dispose = () => {
  // 停止动画循环
  if (animationId) {
    cancelAnimationFrame(animationId)
    animationId = null
  }

  // 清理性能监控
  if (statsMonitor && statsMonitor.dom.parentElement) {
    statsMonitor.dom.parentElement.removeChild(statsMonitor.dom)
    statsMonitor = null
  }

  // 清理控制器
  if (controls) {
    controls.dispose()
    controls = null
  }

  // 清理场景中的所有对象
  disposables.forEach((object) => {
    if (object.geometry) {
      object.geometry.dispose()
    }
    if (object.material) {
      if (Array.isArray(object.material)) {
        object.material.forEach(m => m.dispose())
      } else {
        object.material.dispose()
      }
    }
  })
  disposables.clear()

  // 清理渲染器
  if (renderer) {
    renderer.dispose()
    if (renderer.domElement.parentElement) {
      renderer.domElement.parentElement.removeChild(renderer.domElement)
    }
    renderer = null
  }

  // 清理引用
  resources.clear()
  scene = null
  camera = null
}

// 初始化
const init = async () => {
  try {
    loading.value = true

    // 初始化核心对象
    initScene()
    initCamera()
    initRenderer()
    initControls()

    // 初始化光照
    initLights()

    // 添加辅助工具(开发阶段)
    if (import.meta.env.DEV) {
      addHelpers()
    }

    // 添加示例对象
    addDemoObjects()

    // 初始化性能监控
    initStats()

    // 开始渲染循环
    animate(0)

    // 监听窗口大小变化
    window.addEventListener('resize', handleResize)

    loading.value = false
    loadingProgress.value = 100

    emit('ready', { scene, camera, renderer })

  } catch (error) {
    console.error('初始化失败:', error)
    emit('error', error)
    loading.value = false
  }
}

// 生命周期
onMounted(() => {
  init()
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
  dispose()
})

// 监听背景色变化
watch(() => props.backgroundColor, (newColor) => {
  if (scene) {
    scene.background = new THREE.Color(newColor)
    needsRender = true
  }
})

// 暴露方法给父组件
defineExpose({
  scene,
  camera,
  renderer,
  controls,
  resetCamera,
  captureScreenshot,
  forceRender: () => { needsRender = true }
})
</script>

<style scoped>
.three-scene {
  position: relative;
  width: 100%;
  height: 100%;
}

.scene-container {
  width: 100%;
  height: 100%;
  position: relative;
}

.stats-panel {
  position: absolute;
  top: 10px;
  right: 10px;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 12px;
  border-radius: 4px;
  font-size: 12px;
  min-width: 150px;
}

.stat-item {
  display: flex;
  justify-content: space-between;
  margin-bottom: 8px;
}

.stat-item:last-child {
  margin-bottom: 0;
}

.stat-item span {
  color: #999;
}

.stat-item strong {
  color: #00ff00;
}

.controls-panel {
  position: absolute;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 12px;
  background: rgba(0, 0, 0, 0.7);
  padding: 12px;
  border-radius: 4px;
}

.controls-panel button {
  padding: 8px 16px;
  border: 1px solid rgba(255, 255, 255, 0.3);
  background: rgba(255, 255, 255, 0.1);
  color: white;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
  font-size: 14px;
}

.controls-panel button:hover {
  background: rgba(255, 255, 255, 0.2);
  border-color: rgba(255, 255, 255, 0.5);
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.8);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  color: white;
  z-index: 1000;
}

.loading-spinner {
  width: 50px;
  height: 50px;
  border: 4px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 16px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.loading-overlay p {
  font-size: 16px;
  margin: 0;
}
</style>

二、相机控制与视角切换

2.1 多相机系统实现

vue
<!-- CameraSystem.vue -->
<template>
  <div class="camera-system">
    <ThreeScene ref="sceneRef" @ready="handleSceneReady" />

    <!-- 相机预设 -->
    <div class="camera-presets">
      <h3>相机预设</h3>
      <button
        v-for="preset in cameraPresets"
        :key="preset.name"
        :class="['preset-btn', { active: currentPreset === preset.name }]"
        @click="switchToPreset(preset.name)"
      >
        {{ preset.label }}
      </button>
    </div>

    <!-- 相机控制 -->
    <div class="camera-controls">
      <h3>相机控制</h3>
      <div class="control-group">
        <label>相机类型</label>
        <select v-model="cameraType" @change="switchCameraType">
          <option value="perspective">透视相机</option>
          <option value="orthographic">正交相机</option>
        </select>
      </div>

      <div class="control-group">
        <label>FOV: {{ fov }}</label>
        <input
          type="range"
          min="20"
          max="120"
          v-model.number="fov"
          @input="updateFOV"
        />
      </div>

      <div class="control-group">
        <label>移动速度: {{ moveSpeed }}</label>
        <input
          type="range"
          min="0.1"
          max="2"
          step="0.1"
          v-model.number="moveSpeed"
        />
      </div>

      <div class="control-group">
        <button @click="enableFirstPersonView">第一人称视角</button>
        <button @click="enableOrbitView">环绕视角</button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import ThreeScene from './ThreeScene.vue'
import * as THREE from 'three'
import { FirstPersonControls } from 'three/examples/jsm/controls/FirstPersonControls'

const sceneRef = ref(null)
let scene = null
let camera = null
let renderer = null
let controls = null

// 相机状态
const cameraType = ref('perspective')
const currentPreset = ref('default')
const fov = ref(75)
const moveSpeed = ref(1)

// 相机预设
const cameraPresets = [
  {
    name: 'default',
    label: '默认视角',
    position: new THREE.Vector3(5, 5, 5),
    lookAt: new THREE.Vector3(0, 0, 0),
    fov: 75
  },
  {
    name: 'top',
    label: '俯视图',
    position: new THREE.Vector3(0, 15, 0),
    lookAt: new THREE.Vector3(0, 0, 0),
    fov: 60
  },
  {
    name: 'front',
    label: '正视图',
    position: new THREE.Vector3(0, 2, 10),
    lookAt: new THREE.Vector3(0, 2, 0),
    fov: 75
  },
  {
    name: 'side',
    label: '侧视图',
    position: new THREE.Vector3(10, 2, 0),
    lookAt: new THREE.Vector3(0, 2, 0),
    fov: 75
  },
  {
    name: 'closeup',
    label: '特写',
    position: new THREE.Vector3(2, 1, 2),
    lookAt: new THREE.Vector3(0, 0.5, 0),
    fov: 50
  }
]

// 场景就绪
const handleSceneReady = ({ scene: s, camera: c, renderer: r }) => {
  scene = s
  camera = c
  renderer = r
  controls = sceneRef.value.controls
}

// 切换相机预设
const switchToPreset = (presetName) => {
  const preset = cameraPresets.find(p => p.name === presetName)
  if (!preset || !camera) return

  currentPreset.value = presetName

  // 平滑过渡
  animateCameraTo(
    preset.position,
    preset.lookAt,
    preset.fov,
    1000
  )
}

// 相机动画过渡
const animateCameraTo = (targetPos, targetLookAt, targetFOV, duration) => {
  const startPos = camera.position.clone()
  const startLookAt = controls.target.clone()
  const startFOV = camera.fov
  const startTime = performance.now()

  const animate = () => {
    const elapsed = performance.now() - startTime
    const progress = Math.min(elapsed / duration, 1)
    const eased = easeInOutCubic(progress)

    // 插值位置
    camera.position.lerpVectors(startPos, targetPos, eased)

    // 插值目标点
    controls.target.lerpVectors(startLookAt, targetLookAt, eased)

    // 插值FOV
    camera.fov = THREE.MathUtils.lerp(startFOV, targetFOV, eased)
    camera.updateProjectionMatrix()

    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  animate()
}

// 缓动函数
const easeInOutCubic = (t) => {
  return t < 0.5
    ? 4 * t * t * t
    : 1 - Math.pow(-2 * t + 2, 3) / 2
}

// 切换相机类型
const switchCameraType = () => {
  if (!camera || !renderer) return

  const width = renderer.domElement.clientWidth
  const height = renderer.domElement.clientHeight
  const aspect = width / height

  // 保存当前相机状态
  const currentPosition = camera.position.clone()
  const currentTarget = controls.target.clone()

  if (cameraType.value === 'orthographic') {
    // 切换到正交相机
    const frustumSize = 10
    const newCamera = new THREE.OrthographicCamera(
      -frustumSize * aspect / 2,
      frustumSize * aspect / 2,
      frustumSize / 2,
      -frustumSize / 2,
      0.1,
      1000
    )

    newCamera.position.copy(currentPosition)
    newCamera.lookAt(currentTarget)

    // 替换相机
    camera = newCamera
    sceneRef.value.camera = newCamera

    // 更新控制器
    controls.object = newCamera
    controls.target.copy(currentTarget)

  } else {
    // 切换到透视相机
    const newCamera = new THREE.PerspectiveCamera(
      fov.value,
      aspect,
      0.1,
      1000
    )

    newCamera.position.copy(currentPosition)
    newCamera.lookAt(currentTarget)

    // 替换相机
    camera = newCamera
    sceneRef.value.camera = newCamera

    // 更新控制器
    controls.object = newCamera
    controls.target.copy(currentTarget)
  }
}

// 更新FOV
const updateFOV = () => {
  if (!camera || camera.type !== 'PerspectiveCamera') return

  camera.fov = fov.value
  camera.updateProjectionMatrix()
}

// 启用第一人称视角
const enableFirstPersonView = () => {
  // 实际项目中需要切换到 FirstPersonControls
  console.log('切换到第一人称视角')
}

// 启用环绕视角
const enableOrbitView = () => {
  // 恢复 OrbitControls
  console.log('切换到环绕视角')
}
</script>

<style scoped>
.camera-system {
  position: relative;
  width: 100%;
  height: 100vh;
}

.camera-presets,
.camera-controls {
  position: absolute;
  left: 20px;
  background: rgba(0, 0, 0, 0.8);
  color: white;
  padding: 16px;
  border-radius: 8px;
  min-width: 200px;
}

.camera-presets {
  top: 20px;
}

.camera-controls {
  top: 250px;
}

h3 {
  margin: 0 0 12px 0;
  font-size: 14px;
  color: #ccc;
  text-transform: uppercase;
}

.preset-btn {
  width: 100%;
  padding: 8px 12px;
  margin-bottom: 8px;
  border: 1px solid rgba(255, 255, 255, 0.3);
  background: rgba(255, 255, 255, 0.1);
  color: white;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
  text-align: left;
}

.preset-btn:hover {
  background: rgba(255, 255, 255, 0.2);
}

.preset-btn.active {
  background: rgba(0, 150, 255, 0.5);
  border-color: rgba(0, 150, 255, 0.8);
}

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

.control-group label {
  display: block;
  margin-bottom: 8px;
  font-size: 12px;
  color: #ccc;
}

.control-group select,
.control-group input[type="range"] {
  width: 100%;
}

.control-group button {
  width: 100%;
  padding: 8px;
  margin-bottom: 8px;
  border: 1px solid rgba(255, 255, 255, 0.3);
  background: rgba(255, 255, 255, 0.1);
  color: white;
  border-radius: 4px;
  cursor: pointer;
}

.control-group button:hover {
  background: rgba(255, 255, 255, 0.2);
}
</style>

继续输出文档的剩余部分...

三、光照与材质系统

3.1 光照预设系统

javascript
// lightPresets.js
import * as THREE from 'three'

export const lightPresets = {
  // 工作室光照
  studio: (scene) => {
    const lights = []

    // 主光源
    const keyLight = new THREE.DirectionalLight(0xffffff, 1.2)
    keyLight.position.set(5, 10, 7.5)
    keyLight.castShadow = true
    lights.push(keyLight)

    // 补光
    const fillLight = new THREE.DirectionalLight(0xffffff, 0.5)
    fillLight.position.set(-5, 5, -5)
    lights.push(fillLight)

    // 背光
    const backLight = new THREE.DirectionalLight(0xffffff, 0.3)
    backLight.position.set(0, 5, -10)
    lights.push(backLight)

    // 环境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.3)
    lights.push(ambientLight)

    lights.forEach(light => scene.add(light))
    return lights
  },

  // 自然光
  natural: (scene) => {
    const lights = []

    // 太阳光
    const sunLight = new THREE.DirectionalLight(0xffffcc, 1.5)
    sunLight.position.set(10, 20, 5)
    sunLight.castShadow = true
    lights.push(sunLight)

    // 天空光
    const skyLight = new THREE.HemisphereLight(0x87CEEB, 0x8B4513, 0.6)
    lights.push(skyLight)

    // 环境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.2)
    lights.push(ambientLight)

    lights.forEach(light => scene.add(light))
    return lights
  },

  // 夜晚模式
  night: (scene) => {
    const lights = []

    // 月光
    const moonLight = new THREE.DirectionalLight(0x6666ff, 0.5)
    moonLight.position.set(-5, 10, 5)
    lights.push(moonLight)

    // 环境光
    const ambientLight = new THREE.AmbientLight(0x222244, 0.3)
    lights.push(ambientLight)

    // 点光源
    const pointLight1 = new THREE.PointLight(0xff9000, 1, 10)
    pointLight1.position.set(3, 2, 3)
    lights.push(pointLight1)

    const pointLight2 = new THREE.PointLight(0xff0000, 0.8, 10)
    pointLight2.position.set(-3, 2, -3)
    lights.push(pointLight2)

    lights.forEach(light => scene.add(light))
    return lights
  },

  // 戏剧性光照
  dramatic: (scene) => {
    const lights = []

    // 强主光
    const keyLight = new THREE.SpotLight(0xffffff, 2)
    keyLight.position.set(0, 10, 0)
    keyLight.angle = Math.PI / 6
    keyLight.penumbra = 0.5
    keyLight.castShadow = true
    lights.push(keyLight)

    // 弱环境光
    const ambientLight = new THREE.AmbientLight(0x222222, 0.1)
    lights.push(ambientLight)

    // 边缘光
    const rimLight = new THREE.DirectionalLight(0x6666ff, 0.5)
    rimLight.position.set(-10, 5, -10)
    lights.push(rimLight)

    lights.forEach(light => scene.add(light))
    return lights
  }
}

// 材质预设
export const materialPresets = {
  // 金属材质
  metal: {
    metalness: 1.0,
    roughness: 0.2,
    color: 0xcccccc
  },

  // 塑料材质
  plastic: {
    metalness: 0.0,
    roughness: 0.5,
    color: 0xff0000
  },

  // 木材材质
  wood: {
    metalness: 0.0,
    roughness: 0.8,
    color: 0x8B4513
  },

  // 玻璃材质
  glass: {
    metalness: 0.0,
    roughness: 0.0,
    transparent: true,
    opacity: 0.5,
    color: 0xffffff
  },

  // 橡胶材质
  rubber: {
    metalness: 0.0,
    roughness: 0.9,
    color: 0x333333
  }
}

四、模型加载(GLTF/FBX)

4.1 模型加载器封装

javascript
// modelLoader.js
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import * as THREE from 'three'

export class ModelLoader {
  constructor() {
    this.gltfLoader = new GLTFLoader()
    this.fbxLoader = new FBXLoader()
    this.dracoLoader = new DRACOLoader()

    // 配置 Draco 解码器(用于压缩的 GLTF)
    this.dracoLoader.setDecoderPath('/draco/')
    this.gltfLoader.setDRACOLoader(this.dracoLoader)

    // 加载队列
    this.loadQueue = []
    this.loadedModels = new Map()
  }

  // 加载 GLTF 模型
  async loadGLTF(url, onProgress) {
    return new Promise((resolve, reject) => {
      this.gltfLoader.load(
        url,
        (gltf) => {
          this.processGLTF(gltf)
          this.loadedModels.set(url, gltf)
          resolve(gltf)
        },
        (progress) => {
          if (onProgress) {
            const percent = (progress.loaded / progress.total) * 100
            onProgress(percent)
          }
        },
        (error) => {
          console.error('加载 GLTF 失败:', error)
          reject(error)
        }
      )
    })
  }

  // 处理 GLTF 模型
  processGLTF(gltf) {
    const model = gltf.scene

    // 启用阴影
    model.traverse((child) => {
      if (child.isMesh) {
        child.castShadow = true
        child.receiveShadow = true

        // 优化材质
        if (child.material) {
          child.material.envMapIntensity = 1.5
        }
      }
    })

    // 居中模型
    this.centerModel(model)

    // 缩放到合适大小
    this.normalizeModelScale(model)

    return model
  }

  // 加载 FBX 模型
  async loadFBX(url, onProgress) {
    return new Promise((resolve, reject) => {
      this.fbxLoader.load(
        url,
        (fbx) => {
          this.processFBX(fbx)
          this.loadedModels.set(url, fbx)
          resolve(fbx)
        },
        (progress) => {
          if (onProgress) {
            const percent = (progress.loaded / progress.total) * 100
            onProgress(percent)
          }
        },
        (error) => {
          console.error('加载 FBX 失败:', error)
          reject(error)
        }
      )
    })
  }

  // 处理 FBX 模型
  processFBX(fbx) {
    fbx.traverse((child) => {
      if (child.isMesh) {
        child.castShadow = true
        child.receiveShadow = true
      }
    })

    this.centerModel(fbx)
    this.normalizeModelScale(fbx)

    return fbx
  }

  // 居中模型
  centerModel(model) {
    const box = new THREE.Box3().setFromObject(model)
    const center = box.getCenter(new THREE.Vector3())
    model.position.sub(center)
  }

  // 归一化模型尺寸
  normalizeModelScale(model, targetSize = 5) {
    const box = new THREE.Box3().setFromObject(model)
    const size = box.getSize(new THREE.Vector3())
    const maxDim = Math.max(size.x, size.y, size.z)
    const scale = targetSize / maxDim
    model.scale.multiplyScalar(scale)
  }

  // 获取缓存的模型
  getCached(url) {
    return this.loadedModels.get(url)
  }

  // 清理资源
  dispose() {
    this.loadedModels.forEach((model) => {
      model.scene?.traverse((child) => {
        if (child.geometry) {
          child.geometry.dispose()
        }
        if (child.material) {
          if (Array.isArray(child.material)) {
            child.material.forEach(m => m.dispose())
          } else {
            child.material.dispose()
          }
        }
      })
    })
    this.loadedModels.clear()
  }
}

五、性能监控与优化

5.1 性能监控系统

vue
<!-- PerformanceMonitor.vue -->
<template>
  <div class="performance-monitor">
    <ThreeScene ref="sceneRef" @ready="handleReady" />

    <div class="monitor-panel">
      <h3>性能监控</h3>

      <div class="metrics">
        <div class="metric">
          <span>FPS</span>
          <strong :class="getFPSClass()">{{ metrics.fps }}</strong>
        </div>
        <div class="metric">
          <span>渲染时间</span>
          <strong>{{ metrics.renderTime }}ms</strong>
        </div>
        <div class="metric">
          <span>Draw Calls</span>
          <strong>{{ metrics.drawCalls }}</strong>
        </div>
        <div class="metric">
          <span>三角形</span>
          <strong>{{ formatNumber(metrics.triangles) }}</strong>
        </div>
        <div class="metric">
          <span>纹理</span>
          <strong>{{ metrics.textures }}</strong>
        </div>
        <div class="metric">
          <span>内存</span>
          <strong>{{ metrics.memory }}MB</strong>
        </div>
      </div>

      <div class="optimizations">
        <h4>优化建议</h4>
        <div
          v-for="(suggestion, index) in suggestions"
          :key="index"
          class="suggestion"
        >
          {{ suggestion }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import ThreeScene from './ThreeScene.vue'

const sceneRef = ref(null)
let renderer = null
let updateInterval = null

const metrics = ref({
  fps: 0,
  renderTime: 0,
  drawCalls: 0,
  triangles: 0,
  textures: 0,
  memory: 0
})

const suggestions = ref([])

// 场景就绪
const handleReady = ({ renderer: r }) => {
  renderer = r
  startMonitoring()
}

// 开始监控
const startMonitoring = () => {
  let lastTime = performance.now()
  let frames = 0

  updateInterval = setInterval(() => {
    if (!renderer) return

    // 计算 FPS
    const currentTime = performance.now()
    const deltaTime = currentTime - lastTime
    metrics.value.fps = Math.round((frames * 1000) / deltaTime)
    frames = 0
    lastTime = currentTime

    // 获取渲染信息
    const info = renderer.info
    metrics.value.drawCalls = info.render.calls
    metrics.value.triangles = info.render.triangles
    metrics.value.textures = info.memory.textures

    // 获取内存信息
    if (performance.memory) {
      metrics.value.memory = Math.round(
        performance.memory.usedJSHeapSize / 1048576
      )
    }

    // 生成优化建议
    generateSuggestions()

  }, 1000)

  // 监听渲染帧
  const animate = () => {
    frames++
    requestAnimationFrame(animate)
  }
  animate()
}

// 生成优化建议
const generateSuggestions = () => {
  const newSuggestions = []

  if (metrics.value.fps < 30) {
    newSuggestions.push('FPS 过低,建议降低模型精度或减少光源数量')
  }

  if (metrics.value.drawCalls > 50) {
    newSuggestions.push('Draw Calls 过多,建议合并相同材质的模型')
  }

  if (metrics.value.triangles > 1000000) {
    newSuggestions.push('三角形数量过多,建议使用 LOD 或简化模型')
  }

  if (metrics.value.memory > 500) {
    newSuggestions.push('内存占用过高,建议清理未使用的资源')
  }

  suggestions.value = newSuggestions
}

// FPS 颜色分类
const getFPSClass = () => {
  if (metrics.value.fps >= 55) return 'good'
  if (metrics.value.fps >= 30) return 'warning'
  return 'bad'
}

// 格式化数字
const formatNumber = (num) => {
  return num.toLocaleString()
}

// 清理
onUnmounted(() => {
  if (updateInterval) {
    clearInterval(updateInterval)
  }
})
</script>

<style scoped>
.performance-monitor {
  position: relative;
  width: 100%;
  height: 100vh;
}

.monitor-panel {
  position: absolute;
  top: 20px;
  right: 20px;
  background: rgba(0, 0, 0, 0.9);
  color: white;
  padding: 20px;
  border-radius: 8px;
  min-width: 250px;
  max-width: 350px;
}

h3 {
  margin: 0 0 16px 0;
  font-size: 16px;
  color: #0ff;
  text-transform: uppercase;
}

h4 {
  margin: 16px 0 8px 0;
  font-size: 14px;
  color: #ccc;
}

.metrics {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  margin-bottom: 20px;
}

.metric {
  background: rgba(255, 255, 255, 0.05);
  padding: 12px;
  border-radius: 4px;
}

.metric span {
  display: block;
  font-size: 11px;
  color: #999;
  margin-bottom: 4px;
  text-transform: uppercase;
}

.metric strong {
  display: block;
  font-size: 18px;
  color: #0ff;
}

.metric strong.good {
  color: #0f0;
}

.metric strong.warning {
  color: #ff0;
}

.metric strong.bad {
  color: #f00;
}

.optimizations {
  border-top: 1px solid rgba(255, 255, 255, 0.1);
  padding-top: 16px;
}

.suggestion {
  background: rgba(255, 165, 0, 0.1);
  border-left: 3px solid #ffa500;
  padding: 8px 12px;
  margin-bottom: 8px;
  font-size: 12px;
  border-radius: 4px;
}
</style>

六、真实项目经验总结

6.1 项目背景

我之前做过一个在线家具展示平台,需要在网页上展示各种家具的 3D 模型,用户可以 360 度查看、切换材质、调整尺寸等。这个项目对性能要求很高,因为有些家具模型很复杂,比如一个沙发可能有几十万个三角面。

6.2 遇到的实际问题

问题1:模型加载太慢,首屏白屏时间长

最开始我们直接加载完整的高精度模型,结果用户要等 10 秒才能看到东西,体验很差。后来我做了渐进式加载:先加载一个低精度的预览模型(几百 KB),用户能快速看到效果,然后在后台加载高精度模型并替换。这样首屏时间降到了 2 秒以内。

问题2:多个模型同时展示时卡顿严重

有个场景需要同时展示 20 多件家具,直接渲染的话帧率只有 20 fps。我做了几个优化:首先是 LOD,根据相机距离动态调整模型精度;其次是视锥体剔除,不在视野内的不渲染;还有就是合并材质相同的物体,减少 draw call。这些优化做完后,帧率提升到了 50 fps 以上。

问题3:内存泄漏导致页面越用越卡

用户在切换不同家具时,旧的模型没有被正确清理,内存持续增长。我实现了一个资源管理器,追踪所有的 Geometry、Material、Texture,在切换场景时统一清理。还用 WeakMap 存储引用,避免循环引用。这个问题解决后,页面可以长时间使用而不会变慢。

6.3 技术心得

  1. Three.js 的性能优化是一个系统工程,需要从模型、材质、光照、渲染多个方面考虑。
  2. 资源管理非常重要,Three.js 不会自动清理资源,必须手动 dispose,否则一定会内存泄漏。
  3. 用户体验优先,宁可降低一点画质也要保证流畅度。60 fps 的低精度模型比 20 fps 的高精度模型体验好得多。
  4. 性能监控要做好,能及时发现问题。我们在开发环境集成了 Stats.js,线上也会收集性能数据。
  5. 移动端和 PC 端要区别对待,移动端性能差很多,需要更激进的优化策略。

这个项目让我对 Three.js 有了更深入的理解,不仅是会用 API,更重要的是理解渲染原理,知道怎么优化性能。