在vue2中创建Threejs全局管理器,单例模式
·
-
确保全局只有一个 Three.js 管理器
-
减少重复加载,提升性能
-
主动清理资源,防止内存泄漏
-
包含占位模型和详细的错误日志
-
自动适应容器尺寸变化
-
便于监控和优化
npm install three@0.128.0
我这里使用的是0.128.0版本的threejs
// 在main.js文件中导入ThreeManager管理器
import ThreeManager from '@/views/Blg-xjty/utils/ThreeManager'
// 全局挂载ThreeManager
Vue.prototype.$threeManager = ThreeManager
// 在组件中使用this.$threeManager
// 在非组件中没有vue上下文,不能使用this.$threeManager,需要import ThreeManager from '@/views/Blg-xjty/utils/ThreeManager'之后,使用ThreeManager,比如ThreeManager.init()
// ThreeManager.js全局Three.js管理器 单例模式
import * as THREE from 'three'
// 导入示例组件(注意:这些不再是THREE的一部分,需要单独导入)
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
class ThreeManager {
constructor() {
if (ThreeManager.instance) {
console.log('♻️ 复用ThreeManager单例')
return ThreeManager.instance
}
console.log('🆕 创建新的ThreeManager实例')
console.log('THREE版本:', THREE.REVISION)
console.log(
'THREE.GLTFLoader:',
'已从three/examples/jsm/loaders/GLTFLoader导入'
)
// 检查导入是否正确
console.log('导入检查:', {
GLTFLoader: !!GLTFLoader,
DRACOLoader: !!DRACOLoader,
OrbitControls: !!OrbitControls,
THREE: !!THREE,
})
// 核心Three.js对象
this.renderer = null
this.camera = null
this.scene = null
this.controls = null
this.isInitialized = false
// 加载器(单例,复用)
this.loaders = {
gltf: null,
texture: null,
cubeTexture: null,
}
// 缓存系统
this.cache = {
models: new Map(), // 模型缓存 key: url/modelId -> gltf/obj
textures: new Map(), // 纹理缓存 key: textureUrl -> texture
materials: new Map(), // 材质缓存 key: materialType -> material
geometries: new Map(), // 几何体缓存 key: geometryHash -> geometry
}
// 渲染状态
this.isRendering = false
this.animationFrameId = null
// 性能统计
this.stats = {
totalModelsLoaded: 0,
cacheHits: 0,
cacheMisses: 0,
}
// 开发环境
this.processEnvIsDev = process.env.NODE_ENV === 'development'
// 生产环境
this.processEnvIsPro = process.env.NODE_ENV === 'production'
// 添加:跟踪当前活动模型
this.activeModel = null
this.temporaryObjects = new Set()
ThreeManager.instance = this
}
/**
* 初始化Three.js基础环境(只执行一次)
* @param {HTMLElement} container - DOM容器
*/
async init(container) {
if (this.isInitialized) {
console.log('Three.js已初始化,直接复用')
return
}
console.log('🚀 初始化Three.js基础环境...')
try {
// 1. 创建渲染器(核心单例)
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true, //方便截图,允许保留透明背景
})
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.setSize(container.clientWidth, container.clientHeight)
this.renderer.outputEncoding = THREE.LinearEncoding //使用线性 tonemapping(更接近原始颜色)
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 1
container.appendChild(this.renderer.domElement)
console.log('✅ 渲染器创建完成')
// 2. 创建场景
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x0a192f)
console.log('✅ 场景创建完成')
// 3. 创建相机
this.camera = new THREE.PerspectiveCamera(
45,
container.clientWidth / container.clientHeight,
0.1,
1000
)
this.camera.position.set(5, 5, 5)
this.camera.lookAt(0, 0, 0)
console.log('✅ 相机创建完成')
// 4. 创建控制器
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true
this.controls.dampingFactor = 0.05
this.controls.screenSpacePanning = true
console.log('✅ 控制器创建完成')
// 5. 初始化加载器(单例复用)
this.initLoaders()
// 6. 添加基础光源(可复用)
this.initLights()
// 7. 添加坐标轴辅助(开发时使用)
if (this.processEnvIsDev) {
// const axesHelper = new THREE.AxesHelper(5)
// this.scene.add(axesHelper)
// const gridHelper = new THREE.GridHelper(10, 10)
// this.scene.add(gridHelper)
// console.log('✅ 辅助工具已添加')
}
// 8. 窗口大小变化监听
this.initResizeHandler(container)
this.isInitialized = true
console.log('🎉 Three.js初始化完成')
} catch (error) {
console.error('❌ Three.js初始化失败:', error)
throw error
}
}
/**
* 初始化加载器(单例)
*/
initLoaders() {
console.log('🔧 初始化加载器...')
try {
// GLTF加载器(带DRACO压缩支持)
const dracoLoader = new DRACOLoader()
// 注意:这里使用了在线CDN,您可能需要下载到本地或使用其他方式
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'
)
dracoLoader.setDecoderConfig({ type: 'js' })
console.log('✅ DRACOLoader初始化完成')
this.loaders.gltf = new GLTFLoader()
this.loaders.gltf.setDRACOLoader(dracoLoader)
console.log('✅ GLTFLoader初始化完成')
// 纹理加载器
this.loaders.texture = new THREE.TextureLoader()
this.loaders.cubeTexture = new THREE.CubeTextureLoader()
console.log('✅ 纹理加载器初始化完成')
} catch (error) {
console.error('❌ 加载器初始化失败:', error)
throw error
}
}
/**
* 初始化基础光源(永久存在)
*/
initLights() {
console.log('💡 初始化基础光源...')
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
ambientLight.name = 'ambientLight'
ambientLight.userData = { isPermanent: true }
this.scene.add(ambientLight)
// 平行光/方向光(模拟太阳/主光源)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5)
directionalLight.name = 'directionalLight'
directionalLight.userData = { isPermanent: true }
directionalLight.position.set(5, 10, 7)
directionalLight.castShadow = true
// 优化阴影
directionalLight.shadow.mapSize.width = 2048
directionalLight.shadow.mapSize.height = 2048
directionalLight.shadow.camera.near = 0.5
directionalLight.shadow.camera.far = 500
this.scene.add(directionalLight)
// 点光源 - 从一点向所有方向发射
const pointLight1 = new THREE.PointLight(0xffffff, 1, 100)
pointLight1.position.set(0, 10, 0)
const pointLight2 = new THREE.PointLight(0xffffff, 1, 100)
pointLight2.position.set(0, -10, 0)
this.scene.add(pointLight1, pointLight2)
// 聚光灯 - 锥形光束(模拟手电筒、舞台灯)
// const spotLight = new THREE.SpotLight(0xffffff, 1)
// spotLight.position.set(0, 10, 0)
// spotLight.angle = Math.PI / 6 // 30度锥角
// spotLight.penumbra = 0.5 // 边缘柔和度
// spotLight.castShadow = true
// this.scene.add(spotLight)
// 矩形区域光
const rectAreaLight = new THREE.RectAreaLight(0xffffff, 2, 5, 5)
rectAreaLight.position.set(0, 5, 0)
rectAreaLight.lookAt(0, 0, 0)
this.scene.add(rectAreaLight)
// 辅助光、半球光 - 天空和地面颜色不同(模拟室外环境)
const hemisphereLight = new THREE.HemisphereLight(0xffffbb, 0x080820, 0.5)
hemisphereLight.name = 'hemisphereLight'
hemisphereLight.userData = { isPermanent: true }
this.scene.add(hemisphereLight)
console.log('✅ 光源初始化完成')
}
/**
* 加载设备模型(带缓存)- 修复版
* @param {string} deviceId - 设备ID
* @param {string} modelUrl - 模型URL(可选)
* @returns {Promise<THREE.Group>} 模型组
*/
async loadDeviceModel(deviceId, modelUrl = null) {
console.log(`📤 开始加载设备模型: ${deviceId}`)
const cacheKey = deviceId
// 1. 如果有活动的模型,先清理
if (this.activeModel) {
console.log(
`🗑️ 清理前一个活动模型: ${this.activeModel.userData?.deviceId}`
)
this.removeModelFromScene(this.activeModel)
this.activeModel = null
}
// 2. 检查缓存
if (this.cache.models.has(cacheKey)) {
this.stats.cacheHits++
console.log(`✅ 缓存命中: ${deviceId}`)
const cachedModel = this.cache.models.get(cacheKey)
const clonedModel = this.cloneModel(cachedModel)
this.activeModel = clonedModel
return clonedModel
}
this.stats.cacheMisses++
console.log(`🔄 从源加载模型: ${deviceId}`)
try {
// 3. 实际加载模型
const model = await this.loadModelFromSource(deviceId, modelUrl)
// 4. 预处理模型
this.prepareModel(model)
// 5. 缓存模型
this.cache.models.set(cacheKey, model)
this.stats.totalModelsLoaded++
console.log(`🎉 模型加载完成: ${deviceId}`)
const clonedModel = this.cloneModel(model)
this.activeModel = clonedModel
return clonedModel
} catch (error) {
console.error(`❌ 模型加载失败: ${deviceId}`, error)
// 返回一个占位模型
const placeholder = this.createPlaceholderModel(deviceId)
this.activeModel = placeholder
return placeholder
}
}
/**
* 缓存中没有,就从源url加载模型
*/
async loadModelFromSource(deviceId, modelUrl) {
const url = modelUrl || this.getModelUrlByDeviceId(deviceId)
console.log('📥 加载模型URL:', url)
return new Promise((resolve, reject) => {
this.loaders.gltf.load(
url,
(gltf) => {
console.log('✅ GLTF加载回调触发')
// 详细检查gltf对象
if (!gltf) {
console.error('❌ GLTF对象为空')
reject(new Error('GLTF对象为空'))
return
}
console.log('🔍 GLTF对象检查:', {
hasScene: !!gltf.scene,
sceneType: gltf.scene?.constructor?.name,
scenesCount: gltf.scenes?.length || 0,
animationsCount: gltf.animations?.length || 0,
camerasCount: gltf.cameras?.length || 0,
asset: gltf.asset,
})
let model = null
// 方法1:使用gltf.scene
if (gltf.scene && gltf.scene.isObject3D) {
console.log('✅ 使用 gltf.scene')
model = gltf.scene
}
// 方法2:使用gltf.scenes数组
else if (
gltf.scenes &&
gltf.scenes.length > 0 &&
gltf.scenes[0].isObject3D
) {
console.log('✅ 使用 gltf.scenes[0]')
model = gltf.scenes[0]
}
// 方法3:创建新组并添加所有对象
else {
console.warn('⚠️ 创建新场景组')
model = new THREE.Group()
// 添加所有场景
if (gltf.scenes) {
gltf.scenes.forEach((scene, index) => {
if (scene.isObject3D) {
model.add(scene)
console.log(`✅ 添加场景 ${index}`)
}
})
}
// 添加所有mesh
gltf.scene.traverse((child) => {
if (child.isMesh && !child.parent) {
model.add(child)
console.log('✅ 添加孤立mesh')
}
})
}
// 最终检查
if (!model || !model.isObject3D) {
console.error('❌ 最终model无效')
reject(new Error('加载的模型不是有效的Object3D'))
return
}
console.log('🎯 成功获取模型:', {
name: model.name || '未命名',
type: model.constructor.name,
childrenCount: model.children.length,
uuid: model.uuid,
})
model.userData = {
deviceId,
sourceUrl: url,
loadedAt: Date.now(),
isFromCache: false,
}
resolve(model)
},
(progress) => {
if (progress.total > 0) {
const percent = ((progress.loaded / progress.total) * 100).toFixed(
2
)
console.log(`📊 加载进度: ${percent}%`)
} else {
console.log(`📊 已加载: ${progress.loaded} bytes`)
}
},
(error) => {
console.error('❌ GLTF加载失败:', error)
console.error('错误详情:', error.message)
console.error('错误堆栈:', error.stack)
reject(error)
}
)
})
}
/**
* 预处理模型
*/
prepareModel(model) {
console.log('🔧 预处理模型...')
model.traverse((child) => {
// 启用阴影
if (child.isMesh) {
child.castShadow = true
child.receiveShadow = true
// 优化材质
if (child.material) {
this.optimizeMaterial(child.material)
}
}
})
// 统一缩放和位置
model.scale.set(1, 1, 1)
// 统一位置归零
model.position.set(0, 0, 0)
// 自动居中
const box = new THREE.Box3().setFromObject(model)
const center = box.getCenter(new THREE.Vector3())
model.position.sub(center)
console.log('✅ 模型预处理完成')
}
/**
* 优化材质
*/
optimizeMaterial(material) {
const materialKey = `${material.type}_${material.uuid}`
if (!this.cache.materials.has(materialKey)) {
// 优化材质设置
material.side = THREE.DoubleSide
material.transparent = true
material.opacity = 1.0
// 确保材质有颜色
if (!material.color) {
material.color = new THREE.Color(0xffffff)
}
this.cache.materials.set(materialKey, material)
} else {
// 复用已缓存的材质
return this.cache.materials.get(materialKey)
}
}
/**
* 克隆模型
*/
cloneModel(originalModel) {
console.log('🌀 克隆模型...')
if (!originalModel || !originalModel.isObject3D) {
console.error('❌ 无法克隆无效的模型')
return this.createPlaceholderModel('clone-error')
}
try {
const clone = originalModel.clone(true)
// 深度克隆材质
clone.traverse((child) => {
if (child.isMesh && child.material) {
if (Array.isArray(child.material)) {
child.material = child.material.map((mat) => mat.clone())
} else {
child.material = child.material.clone()
}
}
})
// 标记为克隆
clone.userData = {
...originalModel.userData,
isClone: true,
clonedAt: Date.now(),
originalUuid: originalModel.uuid,
}
console.log('✅ 模型克隆完成')
return clone
} catch (error) {
console.error('❌ 克隆失败:', error)
return this.createPlaceholderModel('clone-failed')
}
}
/**
* 创建占位模型
*/
createPlaceholderModel(deviceId) {
console.log('⚠️ 创建占位模型:', deviceId)
const group = new THREE.Group()
group.name = `Placeholder_${deviceId}`
group.userData = {
deviceId,
isPlaceholder: true,
createdAt: Date.now(),
}
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshStandardMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.5,
wireframe: true,
})
const cube = new THREE.Mesh(geometry, material)
cube.name = 'PlaceholderCube'
group.add(cube)
console.log('✅ 占位模型创建完成')
return group
}
/**
* 从场景中移除模型(并清理资源)
*/
removeModelFromScene(model) {
if (!model || !this.scene) return
console.log('移除模型:', model.name || model.uuid)
// 从场景中移除
if (model.parent) {
model.parent.remove(model)
}
// 从临时对象集合中移除
this.temporaryObjects.delete(model)
// 清理资源
this.disposeObject3D(model)
}
/**
* 清理Object3D及其子对象的资源
*/
disposeObject3D(object) {
if (!object) return
object.traverse((child) => {
if (child.isMesh) {
// 清理几何体
if (child.geometry) {
child.geometry.dispose()
}
// 清理材质
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => this.disposeMaterial(material))
} else {
this.disposeMaterial(child.material)
}
}
}
})
}
/**
* 清理材质资源
*/
disposeMaterial(material) {
if (!material) return
// 清理纹理
const textureProperties = [
'map',
'lightMap',
'bumpMap',
'normalMap',
'specularMap',
'envMap',
'alphaMap',
'aoMap',
'displacementMap',
'emissiveMap',
'metalnessMap',
'roughnessMap',
'gradientMap',
]
textureProperties.forEach((prop) => {
if (material[prop]) {
material[prop].dispose()
}
})
// 清理材质本身
material.dispose()
}
/**
* 移除所有非永久对象 - 新增方法
*/
removeAllNonPermanentObjects() {
if (!this.scene) return
console.log('🧹 移除所有非永久对象...')
const objectsToRemove = []
// 收集所有非永久对象
this.scene.traverse((child) => {
// 保留:光源、控制器、永久标记的对象
if (
!child.userData?.isPermanent &&
!child.name?.includes('Light') &&
!child.name?.includes('light') &&
!child.name?.includes('Helper') &&
!child.name?.includes('Camera') &&
!child.name?.includes('control')
) {
objectsToRemove.push(child)
}
})
// 移除对象
objectsToRemove.forEach((obj) => {
try {
// 从父级移除
if (obj.parent) {
obj.parent.remove(obj)
}
// 清理资源
this.disposeObject3D(obj)
console.log(`移除对象: ${obj.name || obj.uuid}`)
} catch (e) {
console.warn('移除对象失败:', e)
}
})
// 清空临时对象集合
this.temporaryObjects.clear()
// 重置活动模型
this.activeModel = null
console.log(`✅ 移除了 ${objectsToRemove.length} 个对象`)
// 强制垃圾回收(如果可用)
if (typeof window.gc === 'function') {
window.gc()
}
}
/**
* 开始渲染循环
*/
startRenderLoop() {
if (this.isRendering) {
console.log('⏯️ 渲染循环已在运行中')
return
}
this.isRendering = true
console.log('▶️ 启动渲染循环...')
const animate = () => {
if (!this.isRendering) return
this.animationFrameId = requestAnimationFrame(animate)
// 更新控制器
if (this.controls) {
this.controls.update()
}
// 渲染场景
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera)
}
}
animate()
console.log('✅ 渲染循环已启动')
}
/**
* 停止渲染循环
*/
stopRenderLoop() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
this.isRendering = false
console.log('⏸️ 渲染循环已停止')
}
/**
* 窗口大小变化处理
*/
initResizeHandler(container) {
this.handleResize = () => {
if (!this.camera || !this.renderer) return
const width = container.clientWidth
const height = container.clientHeight
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
this.renderer.setSize(width, height)
}
// 使用ResizeObserver或传统方式
if ('ResizeObserver' in window) {
const resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
resizeObserver.observe(container)
this.resizeObserver = resizeObserver
} else {
window.addEventListener('resize', this.handleResize)
}
console.log('✅ 窗口大小监听器已设置')
}
/**
* 完全销毁 - 增强版
*/
destroy() {
console.log('💥 销毁Three.js管理器...')
// 停止渲染
this.stopRenderLoop()
// 清理所有非永久对象
this.removeAllNonPermanentObjects()
// 清理缓存
this.cache.models.forEach((model) => {
this.disposeObject3D(model)
})
this.cache.models.clear()
this.cache.materials.clear()
this.cache.textures.clear()
this.cache.geometries.clear()
// 移除DOM元素
if (this.renderer && this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement)
this.renderer.dispose()
}
// 移除事件监听
if (this.resizeObserver) {
this.resizeObserver.disconnect()
} else {
window.removeEventListener('resize', this.handleResize)
}
// 重置状态
this.renderer = null
this.camera = null
this.scene = null
this.controls = null
this.activeModel = null
this.temporaryObjects.clear()
this.isInitialized = false
console.log('✅ Three.js管理器已销毁')
}
/**
* 获取性能统计 - 增强版
*/
getStats() {
return {
...this.stats,
cacheSize: this.cache.models.size,
activeModel: this.activeModel
? {
name: this.activeModel.name,
deviceId: this.activeModel.userData?.deviceId,
uuid: this.activeModel.uuid,
}
: '无',
temporaryObjects: this.temporaryObjects.size,
memory: performance.memory
? {
used:
Math.round(performance.memory.usedJSHeapSize / 1024 / 1024) +
'MB',
total:
Math.round(performance.memory.totalJSHeapSize / 1024 / 1024) +
'MB',
}
: 'N/A',
isRendering: this.isRendering,
sceneObjects: this.scene?.children.length || 0,
}
}
/**
* 如果地图页面没有传过来模型url,就根据设备ID获取模型url
*/
getModelUrlByDeviceId(deviceId) {
console.log('🔍 获取设备模型URL:', deviceId)
// 根据实际情况修改这里
if (deviceId === 'test-official') {
return 'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf'
}
// 默认返回本地模型
return '/qf.glb'
}
/**
* 重新绑定 Three 到新的容器
*/
rebindContainer(container) {
if (!this.renderer || !this.camera) {
console.error('❌ 无法重新绑定容器')
return
}
const canvas = this.renderer.domElement
console.log('🔄 重新绑定容器...')
// 移动canvas
if (canvas.parentNode !== container) {
if (canvas.parentNode) {
canvas.parentNode.removeChild(canvas)
}
container.appendChild(canvas)
}
// 2. 断开旧 ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
// 3. 重新绑定 ResizeObserver(🔥核心)
this.initResizeHandler(container)
console.log(container.clientWidth, container.clientHeight)
// 4. 强制同步一次尺寸
const width = container.clientWidth
const height = container.clientHeight
this.renderer.setSize(width, height)
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
if (this.controls) {
this.controls.update()
}
console.log('✅ Three.js 已重新绑定容器')
}
}
// 导出单例
const threeManager = new ThreeManager()
export default threeManager
/**
* 在vue组件中,比如地图点击点位事件中,使用如下方式初始化threejs
async handleMarkerClick() {
// 添加loading状态
this.threeLoading = true;
try {
// 1. 初始化或复用Three.js环境
if (!this.$threeManager.isInitialized) {
await this.$threeManager.init(this.$refs.container); //threejs容器
} else {
// 直接DOM操作
const canvas = this.$threeManager.renderer.domElement;
if (canvas.parentNode !== this.$refs.container) {
// 先移除原位置(避免DOM错误)
if (canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
this.$refs.container.appendChild(canvas);
// 更新渲染器尺寸(重要!)
this.$threeManager.renderer.setSize(
this.$refs.container.clientWidth,
this.$refs.container.clientHeight
);
this.$threeManager.camera.aspect =
this.$refs.container.clientWidth / this.$refs.container.clientHeight;
this.$threeManager.camera.updateProjectionMatrix();
}
}
// 2. 加载设备模型
const model = await this.$threeManager.loadDeviceModel(deviceId);
// 3. 开始渲染
this.$threeManager.startRenderLoop();
} catch (error) {
console.error('Three.js操作失败:', error);
this.$message.error('3D视图加载失败');
} finally {
this.threeLoading = false;
}
}
*/
GLTFLoader 主要就是为 .gltf 和 .glb 设计的。我这里用的都是.glb格式的模型文件。
.gltf - JSON 格式的 GLTF 文件
.glb - 二进制格式的 GLTF 文件 ,文件更小,加载更快

更多推荐
所有评论(0)