初探Threejs

80 阅读8分钟

预览地址

代码地址

tips: 模型文件请求会有点慢的,见谅🫰

关于

对于threejs一直以来的印象就是很难、很难、还是很难!

所以一直没有时间来系统学习一下,都是断断续续看过一些文章,对于这个技术栈有一个大概的了解.

于是就想着写个Demo来边学边练.

初始化项目

新建一个vite项目:

npm create vite@latest

我选择的是vue3+ts项目,本人菜鸡,对react熟练度不是很够,ts也只是了解一点😭

项目初始化完毕之后就可以安装我们需要的安装包了:

npm i pinia
npm i three

定义模型信息

先初始化一个状态管理库用来存储模型数据

import { defineStore } from "pinia";

export const useModelsStore = defineStore('app', {
    state: () => ({
    }),
    getters: {
    },
    actions: {

    },
});

然后定义我们的数据

import { defineStore } from "pinia";
import { FBXLoader } from "three/examples/jsm/Addons.js";
import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
import Stats from 'three/addons/libs/stats.module.js';
import * as THREE from "three";


export const useModelsStore = defineStore('app', {
    state: () => ({
        FBXLoader: new FBXLoader(),
        PLYLoader: new PLYLoader(),
        renderer: new THREE.WebGLRenderer({ antialias: true }),
        scene: new THREE.Scene(),
        camera: new THREE.PerspectiveCamera(),
        clock: new THREE.Clock(),
        stats: new Stats(), // FPS计数器
        light: new THREE.PointLight('#e2e1e4', 0),
        group: new THREE.Group(),
        mixer: {},
        // 动作交互&方向
        targetRotationY: 0, // 根据鼠标位置计算目标旋转角度
        rotateSpeed: 0.1,
        moveDirection_ws: new THREE.Vector3(), // 存储世界空间中的前进方向
        moveDirection_ad: new THREE.Vector3(), // 存储世界空间中的前进方向
        // 是否已加载场景
        isLoad: false,
        // 第三人称相机跟随配置
        cameraConfig: {
            distance: 50,    // 相机距离角色的距离
            height: 40,       // 相机高度
            smoothness: 0.1  // 平滑过渡系数,值越小越平滑
        },
        // 模型列表
        models: [
            {
                progress: 0,
                key: "Cheering",
                url: "https://unpkg.com/e-cdn@1.0.0/micro-vue/Cheering.fbx",
                position: [0, 1, 0],
                scale: [.15, .15, .15],
                model: null,
                mixer: null,
                actions: []
            },
        ] as Model[],
        // 动作列表
        keys: [
            {
                key: 'q',
                index: 0,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Crazy Gesture.fbx'
            },
            {
                key: 'w',
                index: 1,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/FastRun.fbx'
            },
            {
                key: 'e',
                index: 2,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Fighting Idle.fbx'
            },
            {
                key: 'r',
                index: 3,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Hip Hop Dancing.fbx'
            },
            {
                key: 'a',
                index: 4,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Standing Run Left.fbx'
            },
            {
                key: 's',
                index: 5,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Running Backward.fbx'

            },
            {
                key: 'd',
                index: 6,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Right Strafe.fbx'
            },
            {
                key: 'f',
                index: 7,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Whatever Gesture.fbx'
            },
            {
                key: 'z',
                index: 8,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Praying.fbx'
            },
            {
                key: 'x',
                index: 9,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Jab Cross.fbx'
            },
            {
                key: 'c',
                index: 10,
                instancs: null,
                progress: 0,
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Samba Dancing.fbx'
            }
        ] as any[],
        // 执行动作
        checkKey: 0,
        // 建筑模型
        buildings: [
            {
                progress: 0,
                key: 'Lucy100k',
                url: 'https://cdn.jsdelivr.net/gh/eug620/Pics@master/micro-vue/Lucy100k.ply',
                position: [50, 19, 50],
                scale: [0.024, 0.024, 0.024],
            }
        ] as Building[],
    }),
    getters: {
    },
    actions: {

    },
});

初始化方法

初始化建筑物

// 初始化建筑物
initBuilding() {
    this.buildings.forEach(async (building: Building) => {
        building.model = await this.PLYLoader.loadAsync(building.url, (event: ProgressEvent) => {
            building.progress =
                Math.round((event.loaded / event.total) * 100 * 100) / 100;
        });
        building.model.scale(...building.scale);
        building.model.computeVertexNormals();

        const material = new THREE.MeshLambertMaterial();
        const mesh = new THREE.Mesh(building.model, material);
        mesh.position.set(...building.position);
        mesh.castShadow = true;
        mesh.receiveShadow = true;
        this.scene.add(mesh);
    })

}

第三人称视角跟随函数 - 跟随物体

// 第三人称视角跟随函数 - 跟随物体
updateThirdPersonCamera(character: THREE.Group) {
    // 计算相机在角色局部坐标系中的目标位置
    // 这个位置在角色后方(distance)和上方(height)
    const targetLocalPosition = new THREE.Vector3(
        0,
        this.cameraConfig.height,
        -this.cameraConfig.distance
    );

    // 将局部坐标转换为世界坐标(考虑角色的旋转)
    const targetWorldPosition = new THREE.Vector3()
        .copy(targetLocalPosition)
        .applyQuaternion(character.quaternion)
        .add(character.position);

    // 平滑过渡到目标位置
    this.camera.position.lerp(targetWorldPosition, this.cameraConfig.smoothness);

    // 让相机看向角色的前方一点(而不是中心点,更自然)
    const lookAtPosition = new THREE.Vector3()
        .set(0, 1, -2)  // 在角色前方一点
        .applyQuaternion(character.quaternion)
        .add(character.position);

    this.camera.lookAt(lookAtPosition);
}

初始化平行光

/**
 * 初始化平行光
 * @param position 光源位置
 * @param target 照向位置
 */
initDirectionalLight(position: THREE.Vector3, color = '#ff0000', target: THREE.Vector3 = new THREE.Vector3(0, 0, 0)) {
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(position.x, position.y, position.z);
    directionalLight.castShadow = true;
    // 提高阴影贴图分辨率(值越大越清晰,但性能消耗越高)
    directionalLight.shadow.mapSize.width = 2048;
    directionalLight.shadow.mapSize.height = 2048;

    // 调整阴影相机的可视范围(控制阴影覆盖区域)
    directionalLight.shadow.camera.near = 5;    // 近平面
    directionalLight.shadow.camera.far = 500;   // 远平面
    directionalLight.shadow.camera.left = -100; // 左边界
    directionalLight.shadow.camera.right = 100; // 右边界
    directionalLight.shadow.camera.top = 100;   // 上边界
    directionalLight.shadow.camera.bottom = -100; // 下边界

    directionalLight.target.position.set(target.x, target.y, target.z);
    this.scene.add(directionalLight);

    // 平行光辅助器
    const helper = new THREE.DirectionalLightHelper(directionalLight, 5, color);
    this.scene.add(helper);
}

初始化网格线辅助

// 初始化网格线辅助
initGridHelper() {
    // 添加网格线辅助(可选,用于调试或网格效果)
    const gridHelper = new THREE.GridHelper(800, 80, '#000000', '#000000');
    gridHelper.position.y = 0.01; // 稍微高于地板避免Z轴冲突
    this.scene.add(gridHelper);
}

初始化地板

// 初始化地板
initPlaneGeometry() {
    // 地板 - 可以反光的地板
    const PlaneGeometry = new THREE.PlaneGeometry(800, 800)
    const MeshLambertMaterial = new THREE.MeshLambertMaterial({ color: '#f2f5f9' })
    const plan = new THREE.Mesh(PlaneGeometry, MeshLambertMaterial)
    plan.rotation.x = -0.5 * Math.PI
    plan.receiveShadow = true
    this.scene.add(plan)
}

初始化灯光

// 初始化灯光
initLight() {
    this.light.intensity = 6999
    this.light.position.set(0, 60, 0);
    this.light.visible = true
    this.light.castShadow = true;
    this.group.add(this.light);
    this.group.add(new THREE.PointLightHelper(this.light)) // 光源辅助器
    this.scene.add(this.group);
}

初始化坐标轴辅助器

// 初始化坐标轴辅助器
initAxesHelper() {
    const axesHelper = new THREE.AxesHelper(100);
    axesHelper.position.set(0, 0.2, 0)
    this.scene.add(axesHelper);
}

初始化锥体几何体(方向指示器)

// 初始化锥体几何体(方向指示器)
initConeGeometry() {
    // 方向指示器(前端的小三角)
    const indicatorGeometry = new THREE.ConeGeometry(0.5, 2, 3);
    const indicatorMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    const indicator = new THREE.Mesh(indicatorGeometry, indicatorMaterial);
    indicator.position.z = -3; // 放在组的前端(-Z方向为前方)
    indicator.rotation.x = Math.PI / 2;
    this.group.add(indicator);
}

初始化运动方向

// 初始化运动方向
initMotion() {
    // 计算前后方向的辅助向量
    const ws = new THREE.Vector3(0, 0, 1); // 局部Z轴正方向为前方

    // 计算左右方向的辅助向量
    const ad = new THREE.Vector3(1, 0, 0); // 局部x轴负方向为左方

    // 将局部前进方向转换为世界空间方向
    this.moveDirection_ws.copy(ws).applyQuaternion(this.group.quaternion);
    this.moveDirection_ad.copy(ad).applyQuaternion(this.group.quaternion);
}

初始化模型

// 初始化模型
async initModels() {
    await Promise.all(this.models.map(this.loadModels));
    this.scene.add(this.group);
}

加载模型

// 加载模型
async loadModels(model: Model): Promise<THREE.Object3D> {
    // 加载模型动作
    await Promise.all(this.keys.map((k) => {
        return new Promise(async (resolve) => {
            k.instancs = await this.FBXLoader.loadAsync(k.url, (event: ProgressEvent) => {
                k.progress =
                    Math.round((event.loaded / event.total) * 100 * 100) / 100;
            })
            resolve(null)
        })
    }))
    // 加载主模型
    return new Promise(async (resolve) => {
        model.model = await this.FBXLoader.loadAsync(
            model.url,
            (event: ProgressEvent) => {
                model.progress =
                    Math.round((event.loaded / event.total) * 100 * 100) / 100;
            }
        );
        model.model.castShadow = true;
        model.model.receiveShadow = true;
        model.model.scale.set(...model.scale)

        model.mixer = new THREE.AnimationMixer(model.model)
        model.model.animations.forEach((item: THREE.AnimationClip, idx: number) => {
            model.actions[idx] = (model.mixer as THREE.AnimationMixer).clipAction(item)
        })
        /**
         * 测试动作
         */
        this.keys.forEach(key => {
            key.instancs.animations.forEach((item: THREE.AnimationClip) => {

                // 使用removePositionTracks函数过滤掉动画中所有控制位置的轨道(更彻底的解决方案)
                if (['w', 'a', 's', 'd'].includes(key.key)) {
                    item.tracks = item.tracks.filter(track => {
                        // 排除所有包含.position的动画轨道
                        return !track.name.includes('.position');
                    });
                }


                model.actions[key.index] = (model.mixer as THREE.AnimationMixer).clipAction(item)
            })
        })
        // 初始化动作
        model.actions[0]?.play()
        // 加入Group
        this.group.add(model.model);
        resolve(model.model);
    });
}

渲染模型

// 渲染模型
async renderModels() {
    this.models.forEach(mod => {
        mod.mixer?.update(this.clock.getDelta())
    })

    // 根据按键状态更新位置和旋转
    if ([1, 4, 5, 6].includes(this.checkKey)) {
        this.initMotion()
        // 按下w奔跑
        if (this.checkKey == 1) { // w
            this.group.position.add(this.moveDirection_ws.multiplyScalar(1));
        }
        if (this.checkKey == 4) { // a
            this.group.position.add(this.moveDirection_ad.multiplyScalar(1));
        }
        if (this.checkKey == 5) {// s
            this.group.position.add(this.moveDirection_ws.multiplyScalar(-1));
        }
        if (this.checkKey == 6) { // d
            this.group.position.add(this.moveDirection_ad.multiplyScalar(-1));
        }
    }

    // 相机跟随
    this.group && this.updateThirdPersonCamera(this.group)

    // 平滑过渡到目标角度(使用 lerp 实现平滑插值)
    this.group.rotation.y = THREE.MathUtils.lerp(this.group.rotation.y, -this.targetRotationY, this.rotateSpeed);


    this.renderer?.render(
        toRaw(this.scene),
        toRaw(this.camera)
    );
    this.stats.update()
}

设置动作响应

async setAnimations(index: number, idx: number) {
    // console.log('setAnimations:', index, idx)
    this.checkKey = idx
    this.models[index]?.actions.forEach((actions, i) => {
        if (idx !== i) {
            actions.stop()
        } else {
            actions.play()
        }
    })
}

初始化Threejs

async init(Doms: HTMLElement) {
    Doms.append(this.renderer.domElement);
    Doms.append(this.stats.dom);
    this.stats.dom.style.position = 'fixed'
    this.stats.dom.style.top = '0'
    this.stats.dom.style.bottom = '0'

    // 初始化建筑物
    this.initBuilding()

    // 初始化场景元素
    this.initAxesHelper()
    this.initConeGeometry()
    this.initLight()
    this.initPlaneGeometry()
    this.initGridHelper()
    // this.initDirectionalLight(new THREE.Vector3(100, 100, 100));
    // this.initDirectionalLight(new THREE.Vector3(-100, 100, -100));
    // this.initDirectionalLight(new THREE.Vector3(100, 100, -100));
    // this.initDirectionalLight(new THREE.Vector3(-100, 100, 100));


    if (!this.isLoad) {
        this.isLoad = true;
        const { offsetWidth, offsetHeight } = Doms;
        this.renderer.setClearColor('#000000')
        this.renderer.setSize(offsetWidth, offsetHeight)
        this.renderer.setAnimationLoop(this.renderModels)
        this.renderer.shadowMap.enabled = true

        this.camera = new THREE.PerspectiveCamera(75, offsetWidth / offsetHeight, 0.1, 1000)

        this.camera.position.set(30, 30, 30); //设置相机位置

        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setSize(offsetWidth, offsetHeight);

        // 交互
        const controls = new OrbitControls(this.camera, this.renderer.domElement);
        controls.update();

        this.initModels();
    }

}

开始使用

ok!一切万事俱备,我们来新建一个页面 模版部分

    <div class="h-full w-full">
        <div ref="refsThree" class="h-full"></div>
    </div>

script部分

import { computed, onMounted, ref } from "vue";
import { useModelsStore } from '@/store/modules/models'
const modelsStore = useModelsStore()

const refsThree = ref()
onMounted(() => {
    modelsStore.init(refsThree.value)
})
const keycode = ref<null | number>(null)

// 监听按键响应事件,执行对应动作
window.addEventListener('keydown', (e: KeyboardEvent) => {
    const current = modelsStore.keys.find((v: any) => v.key === e.key)
    if (current && keycode.value !== e.keyCode) {
        keycode.value = e.keyCode
        modelsStore.setAnimations(0, current.index)
    }
})
// 移除当前动作 恢复默认动作
window.addEventListener('keyup', () => {
    keycode.value = null
    modelsStore.setAnimations(0, 0)
})

// 鼠标锁定状态
let isPointerLocked = false;
// 启动鼠标锁定(需用户交互触发)
window.addEventListener('click', () => {
    document.documentElement.requestPointerLock();
    isPointerLocked = true;
});

// 监听鼠标移动事件,更新鼠标位置
let mouseX = 0;
document.addEventListener('mousemove', (event: MouseEvent) => {
    if (!isPointerLocked) {
        // 鼠标位置变量(归一化坐标,范围[-1,1])
        // 将鼠标坐标归一化到[-1,1](原点在屏幕中心)
        mouseX = (event.clientX / window.innerWidth) * 2 - 1;
        // 根据鼠标位置计算目标旋转角度
        // 限制旋转范围(可选,避免过度旋转)
        modelsStore.targetRotationY = mouseX * Math.PI / 2; // 绕Y轴最大旋转90度
    } else {
        const movementX = event.movementX || event.mozMovementX || 0;
        if (movementX > 0) {
            modelsStore.targetRotationY += (Math.PI / 180); // 绕Y轴旋转
        } else if ((movementX < 0)) {
            modelsStore.targetRotationY -= (Math.PI / 180); // 绕Y轴旋转
        }
    }


})

结语

到这里就结束了,后续还会继续学习改造这个demo🎉🎉🎉

感觉还是有很多bug,大佬们看到可以提示我改正一下,万分感激🙏🙏🙏

Three.js:网页端 3D 创作的「魔法钥匙」

当你在网页上见过旋转的 3D 产品模型、沉浸式虚拟展厅,或是数据驱动的三维可视化图表时,背后很可能藏着 Three.js 的身影。作为基于 WebGL 的 JavaScript 3D 库,它就像一位「技术翻译官」,将复杂的底层图形编程逻辑封装成直观 API,让开发者无需深钻 WebGL 的点线面绘制原理,就能在浏览器中构建出逼真的三维世界。

Three.js 的核心魅力在于「降低门槛却不减能力」。它由西班牙开发者 Ricardo Cabello(Mr.doob)于 2010 年发起,经过十余年社区迭代,已成为网页 3D 领域的事实标准 —— 每月持续更新的版本不断扩充功能边界,从基础的几何体、材质、光源,到高级的物理模拟、骨骼动画、VR/AR 适配,几乎覆盖所有 3D 创作需求。一个经典的 Three.js 应用只需三大核心组件:作为「舞台」的场景(Scene)、决定观察视角的相机(Camera),以及将内容渲染到画布的渲染器(Renderer),几行代码就能实现一个旋转的 3D 立方体,这种简洁性让新手也能快速入门。

如今,Three.js 已渗透到多元领域:游戏开发者用它打造网页端沉浸式关卡,建筑师借助它呈现可交互的建筑模型,数据分析师通过它将枯燥数据转化为立体图表,艺术家则用它创作动态数字装置。更重要的是,它无需任何插件,能在所有支持 WebGL 的现代浏览器中流畅运行,完美适配桌面与移动设备。

核心资源地址