一、场景搭建与渲染循环
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 完整技术实现
基础场景管理器
<!-- 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 多相机系统实现
<!-- 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 光照预设系统
// 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 模型加载器封装
// 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 性能监控系统
<!-- 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 技术心得
- Three.js 的性能优化是一个系统工程,需要从模型、材质、光照、渲染多个方面考虑。
- 资源管理非常重要,Three.js 不会自动清理资源,必须手动 dispose,否则一定会内存泄漏。
- 用户体验优先,宁可降低一点画质也要保证流畅度。60 fps 的低精度模型比 20 fps 的高精度模型体验好得多。
- 性能监控要做好,能及时发现问题。我们在开发环境集成了 Stats.js,线上也会收集性能数据。
- 移动端和 PC 端要区别对待,移动端性能差很多,需要更激进的优化策略。
这个项目让我对 Three.js 有了更深入的理解,不仅是会用 API,更重要的是理解渲染原理,知道怎么优化性能。