返回 导航

Vue.js

hangge.com

Vue.js - 集成Three.js构建三维可视化场景教程3(加载OBJ模型)

作者:hangge | 2026-05-18 09:50
    前文我演示了如何使用 Three.js 加载 GLTF/GLB 模型(点击查看)。除了 GLTF/GLB 模型外,OBJ + MTL 是仍然常见的一种模型格式(尤其是在一些老的模型资源或 CAD 导出中)。下面我将通过样例演示如何加载 .obj 格式的模型。

三、加载 OBJ 模型

1,模型准备

(1)我们将模型文件放置项目的 public/models/obj 目录下,其中 textures 子文件家中放置了 MTL 引用的纹理图片。

(2)打开 MTL 文件,查看纹理材质文件的相对路径设置是否正确。

2,样例代码

下面代码我使用 Three.js 内置的 OBJLoaderMTLLoader 将这个 obj 格式的木桌模型加载并展示。
<template>
    <!-- Three.js 渲染容器 -->
    <div class="three-container" ref="container"></div>
</template>

<script>
// 核心 Three.js
import * as THREE from 'three'
// 轨道控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
// OBJ / MTL 加载器
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js'

export default {
    name: 'ThreeScene',
    props: {
        enableControls: { type: Boolean, default: true },
        width: { type: Number, default: null },
        height: { type: Number, default: null },
        // 模型路径与可选 MTL 路径(可通过 props 传入)
        objPath: { type: String, default: '/models/obj/Wood_Table.obj' },
        mtlPath: { type: String, default: '/models/obj/Wood_Table.mtl' }
    },
    data() {
        return {
            scene: null,
            camera: null,
            renderer: null,
            controls: null,
            rafId: null,
            modelGroup: null // 存放加载进来的模型组(方便统一处理与释放)
        }
    },
    mounted() {
        this.$nextTick(() => {
            this.initThree()
            this.start()
            window.addEventListener('resize', this.onWindowResize, { passive: true })
            document.addEventListener('visibilitychange', this.onVisibilityChange)
        })
    },
    beforeDestroy() {
        this.stop()
        window.removeEventListener('resize', this.onWindowResize)
        document.removeEventListener('visibilitychange', this.onVisibilityChange)
        this.disposeThree()
    },
    methods: {
        /**
         * 初始化 Three.js 场景
         */
        initThree() {
            const container = this.$refs.container

            // 1) 场景
            this.scene = new THREE.Scene()
            this.scene.background = new THREE.Color(0x111111)

            // 2) 相机
            const aspect = this.getWidth() / this.getHeight()
            this.camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000)
            this.camera.position.set(0, 15, 15)

            // 3) 渲染器
            this.renderer = new THREE.WebGLRenderer({ antialias: true })
            this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
            this.renderer.setSize(this.getWidth(), this.getHeight())
            this.renderer.outputEncoding = THREE.sRGBEncoding
            this.renderer.shadowMap.enabled = true
            container.appendChild(this.renderer.domElement)

            // 4) 光源
            this.scene.add(new THREE.AmbientLight(0xffffff, 2.0)) // 环境光(核心)

            const dir = new THREE.DirectionalLight(0xffffff, 2.8) // 主光
            dir.position.set(10, 20, 10)
            dir.castShadow = true
            dir.shadow.mapSize.width = 1024
            dir.shadow.mapSize.height = 1024
            this.scene.add(dir)

            const back = new THREE.DirectionalLight(0xffffff, 0.5)
            back.position.set(-10, 10, -10)
            this.scene.add(back)

            // 5) 地面
            const groundGeo = new THREE.PlaneGeometry(15, 15)
            const groundMat = new THREE.MeshStandardMaterial({ color: 0xcccccc })
            const ground = new THREE.Mesh(groundGeo, groundMat)
            ground.rotation.x = -Math.PI / 2
            ground.position.y = -1.0
            ground.receiveShadow = true
            this.scene.add(ground)

            // 6) 控制器
            if (this.enableControls) {
                this.controls = new OrbitControls(this.camera, this.renderer.domElement)
                this.controls.enableDamping = true
            }

            // 7) 初始化模型容器(用于后续统一 transform / dispose)
            this.modelGroup = new THREE.Group()
            this.scene.add(this.modelGroup)

            // 8) 开始加载 OBJ(如果同时提供 MTL,会优先使用 MTL)
            this.loadOBJModel(this.objPath, this.mtlPath)
        },

        /**
        * 加载 OBJ 模型(支持可选的 MTL)
        * @param {string} objUrl - .obj 文件路径(如 /models/model.obj)
        * @param {string|null} mtlUrl - 可选 .mtl 文件路径(如 /models/model.mtl),若无传 null
        */
        loadOBJModel(objUrl, mtlUrl = null) {
            // LoadingManager 可以用于管理纹理路径或进度
            const manager = new THREE.LoadingManager()

            // 可选:修正资源路径(当 .mtl 中引用了相对纹理路径时)
            // manager.setURLModifier((url) => {
            //   // 如果你的 mtl 中的纹理路径乱,可以在这里做规则替换
            //   return url
            // })

            // 如果有 MTL,则先加载 MTL,再加载 OBJ 并绑定材质
            if (mtlUrl) {
                const mtlLoader = new MTLLoader(manager)
                // 指定 MTL 内纹理的基本路径(如果 MTL 中写的是相对路径)
                // 例如把纹理放 public/models/textures/ 下,就 setTexturePath('/models/textures/')
                // mtlLoader.setTexturePath('/models/textures/') // 如有需要可启用
                mtlLoader.load(
                    mtlUrl,
                    materials => {
                        materials.preload() // 预处理材质

                        // OBJLoader 并将材质应用上去
                        const objLoader = new OBJLoader(manager)
                        objLoader.setMaterials(materials)
                        objLoader.load(
                            objUrl,
                            obj => this.onModelLoaded(obj),
                            xhr => {
                                // 加载进度(作为参考)
                                if (xhr.lengthComputable) {
                                    console.log('OBJ 加载中: ' +
                                        `${(xhr.loaded / xhr.total * 100).toFixed(1)}%`)
                                }
                            },
                            err => {
                                console.error('OBJ 加载失败:', err)
                            }
                        )
                    },
                    xhr => {
                        if (xhr.lengthComputable) {
                            console.log(`MTL 加载中: ${(xhr.loaded / xhr.total * 100).toFixed(1)}%`)
                        }
                    },
                    err => {
                        console.warn('MTL 加载失败,尝试直接加载 OBJ(无材质):', err)
                        // 回退:直接加载 OBJ(无材质)
                        const objLoader = new OBJLoader(manager)
                        objLoader.load(objUrl, obj => this.onModelLoaded(obj))
                    }
                )
            } else {
                // 直接加载 OBJ(无 MTL)
                const objLoader = new OBJLoader(manager)
                objLoader.load(
                    objUrl,
                    obj => this.onModelLoaded(obj),
                    xhr => {
                        if (xhr.lengthComputable) {
                            console.log('OBJ 加载中: '
                                + `${(xhr.loaded / xhr.total * 100).toFixed(1)}%`)
                        }
                    },
                    err => {
                        console.error('OBJ 加载失败:', err)
                    }
                )
            }
        },

        /**
        * 模型加载后的通用处理(中心化、缩放、阴影、加入场景)
        * @param {THREE.Object3D} obj - 加载得到的对象(Group)
        */
        onModelLoaded(obj) {
            // 先清除之前的模型(如果需要支持热替换)
            if (this.modelGroup) {
                // 移除并 dispose 旧模型(保留 modelGroup 本身)
                while (this.modelGroup.children.length) {
                    const child = this.modelGroup.children[0]
                    this.modelGroup.remove(child)
                    // 不完全 dispose 在这里做基础释放(更彻底的释放在 disposeThree)
                }
            }

            // 将 obj 放入 modelGroup(方便后续统一变换)
            this.modelGroup.add(obj)

            // 遍历网格,开启投/受阴影,并为无法正常显示的材质做兜底
            obj.traverse(node => {
                if (node.isMesh) {
                    node.castShadow = true
                    node.receiveShadow = true

                    // 如果材质没有法线,需要 computeVertexNormals(有些 OBJ 导出时会丢失法线)
                    if (!node.geometry.hasAttribute('normal')) {
                        node.geometry.computeVertexNormals()
                    }

                    // 若材质不可见或过暗,可临时替换为基础材质作为调试
                    // if (!node.material) {
                    //   node.material = new THREE.MeshStandardMaterial({ color: 0x999999 })
                    // }
                }
            })

            // 自动居中 + 缩放模型以适配相机视野
            this.frameModelToView(obj)

            console.log('OBJ 模型加载完成')
        },

        /**
        * 自动居中和缩放模型,使其在视图中合适展示
        * @param {THREE.Object3D} object3d
        */
        frameModelToView(object3d) {
            // 计算包围盒
            const box = new THREE.Box3().setFromObject(object3d)
            const size = new THREE.Vector3()
            box.getSize(size)
            const center = new THREE.Vector3()
            box.getCenter(center)

            // 将模型移动到原点(以 center 为参考)
            object3d.position.x += (object3d.position.x - center.x)
            object3d.position.y += (object3d.position.y - center.y)
            object3d.position.z += (object3d.position.z - center.z)

            // 根据最大轴向尺寸计算缩放比例(期望模型占据画面的一定比例)
            const maxDim = Math.max(size.x, size.y, size.z)
            if (maxDim === 0) return
            const fitSize = 8.0 // 期望模型最大边长为多少(可调整)
            const scale = fitSize / maxDim
            object3d.scale.setScalar(scale)
        },

        /**
         * 渲染循环(模型保持静止,不旋转)
         */
        animate() {
            if (this.controls) this.controls.update()
            this.renderer.render(this.scene, this.camera)
            this.rafId = requestAnimationFrame(this.animate)
        },

        start() {
            if (!this.rafId) this.rafId = requestAnimationFrame(this.animate)
        },

        stop() {
            if (this.rafId) {
                cancelAnimationFrame(this.rafId)
                this.rafId = null
            }
        },

        getWidth() {
            return this.width || this.$refs.container.clientWidth || window.innerWidth
        },

        getHeight() {
            return this.height || this.$refs.container.clientHeight || window.innerHeight
        },

        onWindowResize() {
            if (!this.camera || !this.renderer) return
            const w = this.getWidth()
            const h = this.getHeight()
            this.camera.aspect = w / h
            this.camera.updateProjectionMatrix()
            this.renderer.setSize(w, h)
            this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
        },

        onVisibilityChange() {
            if (document.hidden) {
                this.stop()
            } else {
                this.start()
            }
        },

        disposeThree() {
            this.stop()
            if (this.controls) {
                this.controls.dispose()
                this.controls = null
            }

            // 遍历全场景并释放几何体/材质/纹理
            if (this.scene) {
                this.scene.traverse(obj => {
                    if (!obj.isMesh) return
                    if (obj.geometry) obj.geometry.dispose()
                    if (obj.material) {
                        const mat = obj.material
                        if (Array.isArray(mat)) {
                            mat.forEach(m => {
                                if (m.map) m.map.dispose()
                                m.dispose()
                            })
                        } else {
                            if (mat.map) mat.map.dispose()
                            mat.dispose()
                        }
                    }
                })
            }

            // 渲染器销毁
            if (this.renderer) {
                this.renderer.forceContextLoss()
                this.renderer.domElement && this.renderer.domElement.remove()
                this.renderer = null
            }

            this.scene = null
            this.camera = null
            this.modelGroup = null
        }
    }
}
</script>

<style scoped>
.three-container {
    width: 100%;
    height: 100%;
    min-height: 400px;
    position: relative;
    overflow: hidden;
}
</style>

3,运行测试

(1)页面加载后除了会显示地面外,还会加载显示一个 obj 格式的木桌模型。

(2)按住鼠标左键拖动可以将视图绕模型旋转,使用滚轮可以缩放视图,按住右键拖动则可平移视角。
评论

全部评论(0)

回到顶部