✨Vue3+Three.js ✨实现原神MMD模型导入并播放动画✨

2,530 阅读6分钟

前言

本文可以实现对MMD模型的引入以及动作、镜头文件的绑定模型的功能,实现在web端播放MMD文件的效果。
本文基于three官网的案例进行修改的,感兴趣的可以直接去看官网案例(ps:服务器在海外的,出不来多刷几遍):传送门

效果图

202411042246 00_00_00-00_00_30.gif

MMD简单的相关知识

MMD的模型文件通常为.pmx
MMD的动作文件和镜头文件通常都为.vmd,具体区分看作者的命名,还有就是文件的大小,一般动作是 几MB,镜头是几十KB的
动作相关的
    你导入的动作如果不生效,但是你导入的方式又是正确的。就是骨骼名称对不上。模型和作者不是同一个作者制作的就有可能出现这种情况。
    出现腿不动那就是骨骼的IK和FK匹配不上,建议直接换个动作或者换个模型,你会改可以自己改

技术栈

技术栈官网
vue3cn.vuejs.org/
three.jsthreejs.org/

模型获取地址

模之屋:www.aplaybox.com/

本文模型:作者:miHoYo/观海 www.aplaybox.com/details/mod… 本文镜头+动作: 作者:洛洛洛君景 www.aplaybox.com/details/mot…

代码结构

一、three.js和物理引擎的引入

three的安装

    //我使用的是0.153.0版本的,如果对不上可以安装我这个版本
    
    npm install three

引入物理引擎

    //在vue的index.html中引入ammo.js文件,文件会放到完整代码中
    <script src="./src/utils/ammo.wasm.js"></script>
二、初始化three舞台
//引入一些等会要中到的方法以及插件
import * as THREE from 'three'

import Stats from 'three/addons/libs/stats.module.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { OutlineEffect } from 'three/addons/effects/OutlineEffect.js'
import { MMDLoader } from 'three/addons/loaders/MMDLoader.js'
import { MMDAnimationHelper } from 'three/addons/animation/MMDAnimationHelper.js'

// 定义一些变量
let stats: any
let mesh, camera: any, scene: any, renderer, effect: any
let helper: any, ikHelper: any, physicsHelper: any

// 创建three的时间轴,是你的模型和动作,镜头统一
const clock = new THREE.Clock()

// 加载物理引擎
Ammo().then(function (AmmoLib) {
  Ammo = AmmoLib
  init()
})

// 初始化three容器
async function init() {
    // 加载容器
    const container = document.getElementById('info')
      if (!container) {
        return alert('加载失败!!!')
      }
      document.body.appendChild(container)
      
      // 舞台
      scene = new THREE.Scene()
     
      // 配置灯光信息
      // DirectionalLight(),第一个参数是颜色,第二个是亮度
      // position.set(1, 1, 1),是设置灯光的位置,三个参数分别是x,y,z坐标
      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.1)
      directionalLight.position.set(1, 1, 1).normalize()
      // 向舞台中添加灯光
      scene.add(directionalLight)

      // 绘制页面内容(ps直接用默认的就行了)
      renderer = new THREE.WebGLRenderer({ antialias: true })
      renderer.setPixelRatio(window.devicePixelRatio)
      renderer.setSize(window.innerWidth, window.innerHeight)
      container.appendChild(renderer.domElement)
      effect = new OutlineEffect(renderer)
      
      // 定义状态
      stats = new Stats()
      container.appendChild(stats.dom)
      
      // 配置进度条信息
      async function onProgress(xhr: any) {
        if (xhr.lengthComputable) {
          const percentComplete = (xhr.loaded / xhr.total) * 100
          console.log(Math.round(percentComplete, 2) + '% downloaded')
        }
      }
      
      //引入轨道控制器(就是控制镜头的旋转拖拽之类的,一般引入了镜头文件这个就不用了)
       const controls = new OrbitControls(camera, renderer.domElement)
      controls.minDistance = 10
      controls.maxDistance = 100
      
}
三、引入场景模型

因为场景模型是静态的,不会动的,所以使用的加载器load()的方法。直接引入配置就行了

//全都放在info()方法里,最好在scene后面
// 创建MMD加载器
const loaderStore = new MMDLoader()
// 加载MMD场景模型
loaderStore.load(
    '../../model/gufengwutai/wt.pmx', // 模型文件
    async function (mesh) {
      // 场景的初始位置,默认(0,0,0),xyz对应的坐标
      mesh.position.y = 1
      let materials1 = mesh.material as THREE.Material[] | THREE.Material
      // 对每个材质进行处理
      for (let i = 0; i < (materials1 as THREE.Material[]).length; i++) {
        let material = materials1[i]
        // 我这边直接使用的是本身的颜色贴图作为光照贴图,如果你不了解MMD的贴图信息,建议也这样配置
        /* 如果你懂得MMD的光照贴图信息,可以使用
            const texture = new THREE.TextureLoader().load( "textures/water.jpg" );
            引入你的光照贴图
        */
        material.lightMap = material.map // 设置光照贴图
        material.lightMapIntensity = 5 //光照贴图亮度
        material.shininess = 10 // 设置自发光强度
      }
      // 向舞台中添加模型
      scene.add(mesh);
    },
    function (xhr) {
      console.log((xhr.loaded / xhr.total * 100) + '% loadedstore');
    },
    function (error) {
      console.log('An error happened');
    }
  )

四、引入人物模型并绑定人物模型

人物模型是要动的所以采用的加载器的loadWithAnimation()方法,可以同时加载人物模型文件和动作文件

如果出现脚不会动的情况并且你不了解MMD的骨骼,建议直接换一个动作
如果了解MMD的骨骼的话可以自己在MMD中调整一下再导出相应的文件

 // 模型文件
  const modelFile = '../../public/ayaka/神里绫华.pmx'
  // 动作文件
  const vmdFiles = ['../../public/move/霜雪千年.vmd']
  
   // 动画辅助器,管理动画信息的,默认就好
  helper = new MMDAnimationHelper({
    afterglow: 2.0
  })
  
    //放在info()方法里,最好在scene后面
   loader.loadWithAnimation(
    modelFile,
    vmdFiles,
    async function (mmd) {
      mesh = mmd.mesh
      mesh.position.y = 1
      let materials = mesh.material as THREE.Material[] | THREE.Material
      // 对每个材质进行处理
      for (let i = 0; i < (materials as THREE.Material[]).length; i++) {
        let material = materials[i]
        // 修改材质的光照属性
        material.lightMap = material.map
        //自发光强度,模型太暗可以调高这个值
        material.lightMapIntensity = 3
        material.shininess = 100 // 设置自发光强度
      }

      // 将动作文件和人物文件进行绑定
      await helper.add(mesh, {
        animation: mmd.animation,
        physics: true
      })

        
      loader.loadAnimation(cameraFiles, camera, function (cameraAnimation: any) {
        helper.add(camera, {
          animation: cameraAnimation
        });
      }, onProgress, null)

      // 绑定骨骼
      ikHelper = await helper.objects.get(mesh).ikSolver.createHelper()
      ikHelper.visible = false
      scene.add(ikHelper)
      physicsHelper = await helper.objects.get(mesh).physics.createHelper()
      physicsHelper.visible = false
      scene.add(physicsHelper)
       
       // 绘制容器信息
      function onWindowResize() {
        camera.aspect = window.innerWidth / window.innerHeight
        camera.updateProjectionMatrix()
        effect.setSize(window.innerWidth, window.innerHeight)
      }
      onWindowResize()
      
       //动画加入时间轴方法
      function animate() {
        requestAnimationFrame(animate)
        stats.begin()
        render()
        stats.end()
      }
      animate()
      
      //渲染方法
      function render() {
        helper.update(clock.getDelta())
        effect.render(scene, camera)
      }
      scene.add(mesh)
    },

    onProgress,
    null
  )

五、镜头的加载

使用的镜头最好是和动作出自同一个作者的,可能会很怪 如果要加入音频也同样的方法加入

  // 镜头文件
  const cameraFiles = ['../../public/move/camera.vmd']
      
  // 放在模型文件加载的回调中
  loader.loadAnimation(cameraFiles, camera, function (cameraAnimation: any) {
    helper.add(camera, {
      animation: cameraAnimation
    });
  }, onProgress, null)
  

完整代码

app.vue

我这边偷懒了,直接在app.vue上引入了,你可以自己弄个组件引入

<template>
  <div id="info" class="sss"></div>
</template>

<script lang="ts" setup>
  import './utils/index'
</script>
<style scoped lang="css">
.sss {
  width: 100vw;
  height: 100vh;
  background-color: black;
}
</style>
index.ts
import * as THREE from 'three'

import Stats from 'three/addons/libs/stats.module.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { OutlineEffect } from 'three/addons/effects/OutlineEffect.js'
import { MMDLoader } from 'three/addons/loaders/MMDLoader.js'
import { MMDAnimationHelper } from 'three/addons/animation/MMDAnimationHelper.js'

let stats: any

let mesh, camera: any, scene: any, renderer, effect: any
let helper: any, ikHelper: any, physicsHelper: any

// 时间轴
const clock = new THREE.Clock()

// 物理动画
Ammo().then(function (AmmoLib) {
  Ammo = AmmoLib
  init()
})

async function init() {
  const container = document.getElementById('info')
  if (!container) {
    return alert('加载失败!!!')
  }
  document.body.appendChild(container)

  //定义镜头
  camera = new THREE.PerspectiveCamera(
    45,
    window.innerWidth / window.innerHeight,
    1,
    2000
  )
  camera.position.z = 30

  // 舞台
  scene = new THREE.Scene()

  // 灯光
  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.1)
  directionalLight.position.set(1, 1, 1).normalize()
  scene.add(directionalLight)

  // 绘制
  renderer = new THREE.WebGLRenderer({ antialias: true })
  renderer.setPixelRatio(window.devicePixelRatio)
  renderer.setSize(window.innerWidth, window.innerHeight)
  container.appendChild(renderer.domElement)
  effect = new OutlineEffect(renderer)

  // STATS
  stats = new Stats()
  container.appendChild(stats.dom)

  // 加载进度条
  async function onProgress(xhr: any) {
    if (xhr.lengthComputable) {
      const percentComplete = (xhr.loaded / xhr.total) * 100
      console.log(Math.round(percentComplete, 2) + '% downloaded')
    }
  }

  // 模型文件
  const modelFile = '../../public/ayaka/神里绫华.pmx'
  // const modelFile = '../../model/kizunaai/kafka.pmx'

  // 动作文件
  // const modelFile = '../../src/mmd/miku/miku_v2.pmd'
  const vmdFiles = ['../../public/move/霜雪千年.vmd']

  // 镜头文件
  const cameraFiles = ['../../public/move/camera.vmd']
  // const vmdFiles = ['../../src/mmd/vmds/wavefile_v2.vmd']

  
  // 动画辅助器
  helper = new MMDAnimationHelper({
    afterglow: 2.0
  })

  const loader = new MMDLoader()
  const loaderStore = new MMDLoader()

  loaderStore.load(
    '../../model/gufengwutai/wt.pmx',
    async function (mesh) {
      mesh.position.y = 1
      let materials1 = mesh.material as THREE.Material[] | THREE.Material
      // 对每个材质进行处理
      for (let i = 0; i < (materials1 as THREE.Material[]).length; i++) {
        let material = materials1[i]
        material.lightMap = material.map // 清除光照贴图
        // material.emissive = new THREE.Color(0xffffff) // 设置自发光颜色
        material.lightMapIntensity = 5
        material.shininess = 1000 // 设置自发光强度
      }

      let materials2 = mesh.material
      for (let i = 0, il = materials2.length; i < il; i++) {
        materials2[i].emissive.emissiveIntensity = 2
      }

      scene.add(mesh);
    },
    function (xhr) {
      console.log((xhr.loaded / xhr.total * 100) + '% loadedstore');
    },
    function (error) {
      console.log('An error happened');
    }
  )

  loader.loadWithAnimation(
    modelFile,
    vmdFiles,
    async function (mmd) {
      mesh = mmd.mesh
      mesh.position.y = 1
      let materials = mesh.material as THREE.Material[] | THREE.Material
      // 对每个材质进行处理
      for (let i = 0; i < (materials as THREE.Material[]).length; i++) {
        let material = materials[i]
        // 修改材质的光照属性
        material.lightMap = material.map // 清除光照贴图
        // material.emissive = new THREE.Color(0xffffff) // 设置自发光颜色
        material.lightMapIntensity = 3
        material.shininess = 100 // 设置自发光强度
      }

      await helper.add(mesh, {
        animation: mmd.animation,
        physics: true
      })

      loader.loadAnimation(cameraFiles, camera, function (cameraAnimation: any) {
        helper.add(camera, {
          animation: cameraAnimation
        });
      }, onProgress, null)

      ikHelper = await helper.objects.get(mesh).ikSolver.createHelper()
      ikHelper.visible = false
      scene.add(ikHelper)

      physicsHelper = await helper.objects.get(mesh).physics.createHelper()
      physicsHelper.visible = false
      scene.add(physicsHelper)

      function onWindowResize() {
        camera.aspect = window.innerWidth / window.innerHeight
        camera.updateProjectionMatrix()
        effect.setSize(window.innerWidth, window.innerHeight)
      }
      onWindowResize()

      function animate() {
        requestAnimationFrame(animate)
        stats.begin()
        render()
        stats.end()
      }
      animate()

      function render() {
        helper.update(clock.getDelta())
        effect.render(scene, camera)
      }
      scene.add(mesh)
    },

    onProgress,
    null
  )

  const controls = new OrbitControls(camera, renderer.domElement)
  controls.minDistance = 10
  controls.maxDistance = 100
}
ammo.wasm.js

额,这个行数太多了,直接给个cdn地址吧,你可以打开这个地址复制下来新建一个js文件放进去 threejs.org/examples/js…

结语

如果有人要完整的项目的话,我就弄个gitee仓库吧(ps:主要是懒不想去拆项目中无关的信息)

这个只是根据官网的案例简单的转换成Vue的方式。很简单的引用,如果你想追求更完美的话,就去three技术文档了解一下相关的渲染的信息吧