返回 导航

Vue.js

hangge.com

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

作者:hangge | 2026-05-19 08:42
    FBX 格式的模型在工业/旧导出管线里很常见,但由于 FBX 文件通常较大,若用于 Web 还是推荐将 FBX 转为 glTF/glb(更适合 Web、体积小且加载友好)。当然 Three.jsFBX 格式的模型也是提供了支持,下面通过样例进行演示。

四、加载 FBX 模型

1,模型准备

我们将需要加载的 FBX 模型文件放置项目的 public/models/fbx 目录下。

2,样例代码

下面代码我使用 Three.js 内置的 FBXLoader 加载这个 glb 格式的房屋模型。
<template>
    <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'
// FBX 加载器
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
// 如需支持 TGA 贴图(如果 FBX 的贴图是 .tga),取消下面注释并安装支持库
// import { TGALoader } from 'three/examples/jsm/loaders/TGALoader.js'

export default {
    name: 'ThreeFBXScene',
    props: {
        enableControls: { type: Boolean, default: true },
        width: { type: Number, default: null },
        height: { type: Number, default: null }
    },
    data() {
        return {
            scene: null,
            camera: null,
            renderer: null,
            controls: null,
            rafId: null,
            model: null,
            mixers: [],           // 存放 AnimationMixer(若模型包含动画)
            clock: new THREE.Clock()
        }
    },
    mounted() {
        this.$nextTick(() => {
            this.initThree()
            // 绑定 this 到 animate,确保 requestAnimationFrame 中的上下文正确
            this.animate = this.animate.bind(this)
            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: {
        getWidth() {
            return this.width || this.$refs.container.clientWidth || window.innerWidth
        },
        getHeight() {
            return this.height || this.$refs.container.clientHeight || window.innerHeight
        },

        initThree() {
            const container = this.$refs.container

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

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

            // 渲染器
            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)

            // 光源(调整以让模型不显得很暗)
            this.scene.add(new THREE.AmbientLight(0xffffff, 1.0)) // 环境光(核心)

            const dir = new THREE.DirectionalLight(0xffffff, 2.0) // 主光
            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)

            // 地面
            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)

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

            // 加载 FBX 模型
            this.loadFBXModel('/models/fbx/house.fbx')
        },

        loadFBXModel(url) {
            const loader = new FBXLoader()

            // 如果你有外部贴图且是 .tga,可以提供 TGALoader:
            // const tgaLoader = new TGALoader()
            // loader.setTexturePath('/models/textures/')
            // loader.setResourcePath('/models/')

            loader.load(
                url,
                fbx => {
                    // FBX 可能自带多个层级
                    this.model = fbx

                    // 若包含动画,创建 AnimationMixer 并播放第一个 animation
                    if (fbx.animations && fbx.animations.length) {
                        const mixer = new THREE.AnimationMixer(fbx)
                        this.mixers.push(mixer)
                        // 默认播放全部 clip(或选择特定 clip)
                        fbx.animations.forEach(clip => {
                            const action = mixer.clipAction(clip)
                            action.play()
                        })
                    }

                    // 遍历网格,启用阴影并调整贴图编码(如果有贴图)
                    fbx.traverse(node => {
                        if (node.isMesh) {
                            node.castShadow = true
                            node.receiveShadow = true

                            // 贴图色彩空间处理:贴图通常应为 sRGB
                            if (node.material) {
                                const mat = node.material
                                // 处理数组材质或单一材质
                                const mats = Array.isArray(mat) ? mat : [mat]
                                mats.forEach(m => {
                                    if (m.map) {
                                        // 将贴图设置为 sRGB(颜色贴图)
                                        m.map.encoding = THREE.sRGBEncoding
                                        m.needsUpdate = true
                                    }
                                    // 某些 FBX 导出会把法线当作颜色贴图,需要识别并设置正确
                                    if (m.normalMap) {
                                        m.normalMap.encoding = THREE.LinearEncoding
                                        m.needsUpdate = true
                                    }
                                })
                            }
                        }
                    })

                    // 根据模型尺寸自适应缩放与居中
                    const box = new THREE.Box3().setFromObject(fbx)
                    const size = box.getSize(new THREE.Vector3()).length()
                    const center = box.getCenter(new THREE.Vector3())

                    // 把模型移动到原点附近
                    fbx.position.x += (fbx.position.x - center.x)
                    fbx.position.z += (fbx.position.z - center.z)

                    this.scene.add(fbx)
                },
                xhr => {
                    if (xhr.lengthComputable) {
                        console.log(`模型加载中: ${(xhr.loaded / xhr.total * 100).toFixed(1)}%`)
                    } else {
                        console.log(`已加载 ${xhr.loaded} 字节`)
                    }
                },
                err => {
                    console.error('FBX 加载失败:', err)
                }
            )
        },

        // 渲染循环:更新动画混合器
        animate() {
            const delta = this.clock.getDelta()
            if (this.mixers.length) {
                this.mixers.forEach(m => m.update(delta))
            }
            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
            }
        },

        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
            }

            // 清理动画 mixer
            this.mixers.forEach(m => {
                // 断开所有动作
                m.uncacheRoot(m.getRoot && m.getRoot())
            })
            this.mixers = []

            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()
                                if (m.lightMap) m.lightMap.dispose && m.lightMap.dispose()
                                if (m.envMap) m.envMap.dispose && m.envMap.dispose()
                                m.dispose()
                            })
                        } else {
                            if (mat.map) mat.map.dispose()
                            if (mat.lightMap) mat.lightMap.dispose && mat.lightMap.dispose()
                            if (mat.envMap) mat.envMap.dispose && mat.envMap.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.model = null
        }
    }
}
</script>

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

3,运行测试

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

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

全部评论(0)

回到顶部