前言
本文可以实现对MMD模型的引入以及动作、镜头文件的绑定模型的功能,实现在web端播放MMD文件的效果。
本文基于three官网的案例进行修改的,感兴趣的可以直接去看官网案例(ps:服务器在海外的,出不来多刷几遍):传送门
效果图
MMD简单的相关知识
MMD的模型文件通常为.pmx
MMD的动作文件和镜头文件通常都为.vmd,具体区分看作者的命名,还有就是文件的大小,一般动作是 几MB,镜头是几十KB的
动作相关的
你导入的动作如果不生效,但是你导入的方式又是正确的。就是骨骼名称对不上。模型和作者不是同一个作者制作的就有可能出现这种情况。
出现腿不动那就是骨骼的IK和FK匹配不上,建议直接换个动作或者换个模型,你会改可以自己改
技术栈
| 技术栈 | 官网 |
|---|---|
| vue3 | cn.vuejs.org/ |
| three.js | threejs.org/ |
模型获取地址
本文模型:作者: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技术文档了解一下相关的渲染的信息吧