939 lines
39 KiB
C#
939 lines
39 KiB
C#
using Gallop;
|
|
using LibMMD.Material;
|
|
using LibMMD.Model;
|
|
using LibMMD.Reader;
|
|
using LibMMD.Unity3D;
|
|
using LibMMD.Writer;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using UnityEngine;
|
|
using UnityEngine.Rendering;
|
|
using Gallop.Live.Cutt; // For FacialMouthId
|
|
using static BillboardBuilder;
|
|
using static LibMMD.Model.Morph;
|
|
using static LibMMD.Model.SkinningOperator;
|
|
//using UnityGLTF;
|
|
|
|
|
|
public class ModelExporter
|
|
{
|
|
static string modelInfo = $"Generated by UmaViewer {Application.version}, Copyright Cygames \nLink : https://github.com/katboi01/UmaViewer";
|
|
|
|
// 仅过滤浮点烘焙产生的近零噪声;平方阈值 1e-12 对应实际位移 1e-6。
|
|
private const float MorphVertexNoiseSqrThreshold = 0.000000000001f;
|
|
private const int VmdMorphNameMaxShiftJisBytes = 15;
|
|
|
|
// 标准 MMD 表情按部位收集,最终烘焙为 Blender、MMD 与 MMDSkin 均可见的 Vertex Morph。
|
|
private enum MorphPart
|
|
{
|
|
Eyebrow,
|
|
Eye,
|
|
Mouth
|
|
}
|
|
|
|
public static void ExportModel(UmaContainerCharacter container, string path)
|
|
{
|
|
PMXExportStateSnapshot stateSnapshot = new PMXExportStateSnapshot(container);
|
|
try
|
|
{
|
|
stateSnapshot.FreezeForExport();
|
|
// PMX 与 VMD 共用同一套 MMD 默认 A-pose,避免导出当前动画帧或近似 T-pose。
|
|
stateSnapshot.ApplyMmdReferencePose();
|
|
container.FaceDrivenKeyTarget?.FacialResetAll();
|
|
AddBlendShape(container);
|
|
BuildBillboard(container);
|
|
|
|
var textures = TextureExporter.ExportAllTexture(Path.GetDirectoryName(path), container.gameObject);
|
|
var model = ReadPMXModel(container, textures, container.CharaEntry);
|
|
|
|
using (FileStream fileStream = new FileStream(path, FileMode.Create))
|
|
using (BinaryWriter writer = new BinaryWriter(fileStream))
|
|
{
|
|
var config = new ModelConfig() { GlobalToonPath = "Toon" };
|
|
PMXWriter.Write(writer, model, config);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
RemoveBillboard(container);
|
|
ClearBlendShape(container);
|
|
}
|
|
finally
|
|
{
|
|
// 临时导出资源清理失败时,也必须恢复角色的姿势和运行时组件。
|
|
stateSnapshot.Dispose();
|
|
}
|
|
}
|
|
|
|
UmaViewerUI.Instance.ShowMessage($"PMX Save at {path}", UIMessageType.Success);
|
|
}
|
|
|
|
public static void ExportModel(UmaContainer container, string path)
|
|
{
|
|
|
|
var textures = TextureExporter.ExportAllTexture(Path.GetDirectoryName(path), container.gameObject);
|
|
var model = ReadPMXModel(container, textures);
|
|
|
|
FileStream fileStream = new FileStream(path, FileMode.Create);
|
|
BinaryWriter writer = new BinaryWriter(fileStream);
|
|
var config = new ModelConfig() { GlobalToonPath = "Toon" };
|
|
PMXWriter.Write(writer, model, config);
|
|
|
|
writer.Close();
|
|
fileStream.Close();
|
|
|
|
UmaViewerUI.Instance.ShowMessage($"PMX Save at {path}", UIMessageType.Success);
|
|
}
|
|
|
|
public static RawMMDModel ReadPMXModel(UmaContainer container, string[] textures, CharaEntry entry = null)
|
|
{
|
|
RawMMDModel model = new RawMMDModel();
|
|
if (entry != null)
|
|
{
|
|
model.Name = entry.Name;
|
|
model.NameEn = entry.GetName();
|
|
model.Description = model.DescriptionEn = $"{entry.GetName()}\n{modelInfo}";
|
|
}
|
|
else
|
|
{
|
|
model.Name = container.gameObject.name;
|
|
model.NameEn = container.gameObject.name;
|
|
model.Description = model.DescriptionEn = $"{container.gameObject.name}\n{modelInfo}";
|
|
}
|
|
|
|
// 仅从蒙皮引用收集骨骼,避免把 Handle、Pole、Target 等运行时控制节点导出为 PMX 骨骼。
|
|
var rootBone = container.transform.Find("Position");
|
|
if (rootBone == null)
|
|
{
|
|
rootBone = container.transform;
|
|
}
|
|
List<Renderer> renderers = new List<Renderer>(container.GetComponentsInChildren<Renderer>());
|
|
// Geometry, materials and morphs must consume the same mesh-only renderer sequence.
|
|
renderers.RemoveAll(renderer => !(renderer is MeshRenderer) && !(renderer is SkinnedMeshRenderer));
|
|
PMXPhysicsExporter.Context physicsContext = null;
|
|
if (container is UmaContainerCharacter physicsCharacter)
|
|
{
|
|
physicsContext = PMXPhysicsExporter.Prepare(physicsCharacter, rootBone);
|
|
}
|
|
PMXBoneExporter.Result boneResult = PMXBoneExporter.Build(
|
|
rootBone,
|
|
container.transform,
|
|
renderers,
|
|
physicsContext != null ? physicsContext.DynamicBones : null);
|
|
|
|
//Read vertices And triangles
|
|
List<int> triangles = new List<int>();
|
|
model.Vertices = ReadVerticesAndTriangles(renderers, boneResult.BoneIndexes, ref triangles, container.transform);
|
|
model.TriangleIndexes = triangles.ToArray();
|
|
|
|
//Read Texture reference
|
|
foreach (var texture in textures)
|
|
{
|
|
model.TextureList.Add(new MMDTexture(texture));
|
|
}
|
|
|
|
model.Parts = ReadPartMaterials(renderers, model);
|
|
model.Bones = boneResult.Bones;
|
|
if(container is UmaContainerCharacter chara)
|
|
{
|
|
ClearBlendShape(chara);
|
|
AddBlendShape(chara);
|
|
}
|
|
model.Morphs = ReadMorph(renderers);
|
|
if (physicsContext != null)
|
|
{
|
|
PMXPhysicsExporter.Build(physicsContext, container.transform, boneResult, model);
|
|
}
|
|
else
|
|
{
|
|
model.Rigidbodies = new MMDRigidBody[0];
|
|
model.Joints = new MMDJoint[0];
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
public static Vector3[] Vec4ToVec3(Vector4[] vector4s)
|
|
{
|
|
Vector3[] tmp = new Vector3[vector4s.Length];
|
|
for (int i = 0; i < vector4s.Length; i++)
|
|
{
|
|
tmp[i] = new Vector3(vector4s[i].x, vector4s[i].y, vector4s[i].z);
|
|
}
|
|
return tmp;
|
|
}
|
|
|
|
public static Vector3[] CalDelta(Vector3[] ori, Vector3[] end)
|
|
{
|
|
Vector3[] tmp = new Vector3[ori.Length];
|
|
for (int i = 0; i < ori.Length; i++)
|
|
{
|
|
tmp[i] = end[i] - ori[i];
|
|
}
|
|
return tmp;
|
|
}
|
|
|
|
private static void AddBlendShape(UmaContainerCharacter container)
|
|
{
|
|
if (!container.FaceDrivenKeyTarget) return;
|
|
SkinnedMeshRenderer faceMesh = null;
|
|
SkinnedMeshRenderer eyebrowMesh = null;
|
|
SkinnedMeshRenderer earMesh = null;
|
|
SkinnedMeshRenderer mouthMesh = null; // New
|
|
SkinnedMeshRenderer eyeMesh = null; // New
|
|
|
|
foreach (SkinnedMeshRenderer s in container.GetComponentsInChildren<SkinnedMeshRenderer>())
|
|
{
|
|
if (s.name.Equals("M_Face")) faceMesh = s;
|
|
else if (s.name.Equals("M_Mayu")) eyebrowMesh = s;
|
|
else if (s.name.Equals("M_Hair")) earMesh = s;
|
|
else if (s.name.Equals("M_Mouth")) mouthMesh = s; // New
|
|
else if (s.name.Equals("M_Eye")) eyeMesh = s; // New
|
|
}
|
|
|
|
var facial = container.FaceDrivenKeyTarget;
|
|
Action<SkinnedMeshRenderer, List<FacialMorph>, Mesh> addBlendShapePart = delegate (SkinnedMeshRenderer skin, List<FacialMorph> morphs, Mesh baseMesh)
|
|
{
|
|
container.FaceOverrideData = null;
|
|
HashSet<string> usedNames = new HashSet<string>(StringComparer.Ordinal);
|
|
for (int blendShapeIndex = 0; blendShapeIndex < skin.sharedMesh.blendShapeCount; blendShapeIndex++)
|
|
{
|
|
usedNames.Add(skin.sharedMesh.GetBlendShapeName(blendShapeIndex));
|
|
}
|
|
|
|
foreach (var morph in morphs)
|
|
{
|
|
facial.ClearAllWeights();
|
|
// [Fix] Reset weight to 1 (User request: 10x was too strong)
|
|
morph.weight = 1;
|
|
facial.ChangeMorph();
|
|
|
|
Mesh deltaMesh = new Mesh();
|
|
skin.BakeMesh(deltaMesh);
|
|
|
|
// VMD 名称限制为 Shift-JIS 15 字节;冲突时追加稳定数字后缀,不能静默丢表情。
|
|
string validName = MakeUniqueShiftJisName(morph.name, usedNames, VmdMorphNameMaxShiftJisBytes);
|
|
usedNames.Add(validName);
|
|
|
|
// [Fix] Use weight 100.0f (Standard Unity Full Weight)
|
|
// Previous value 1.0f meant "This delta is for 1% weight", causing MMD (100%) to explode the mesh 100x.
|
|
skin.sharedMesh.AddBlendShapeFrame(validName, 100.0f,
|
|
CalDelta(baseMesh.vertices, deltaMesh.vertices),
|
|
CalDelta(baseMesh.normals, deltaMesh.normals),
|
|
CalDelta(Vec4ToVec3(baseMesh.tangents), Vec4ToVec3(deltaMesh.tangents)));
|
|
}
|
|
};
|
|
|
|
if (faceMesh)
|
|
{
|
|
Mesh basefaceMesh = new Mesh();
|
|
faceMesh.BakeMesh(basefaceMesh);
|
|
addBlendShapePart(faceMesh, facial.EyeBrowMorphs, basefaceMesh);
|
|
addBlendShapePart(faceMesh, facial.EyeMorphs, basefaceMesh);
|
|
addBlendShapePart(faceMesh, facial.MouthMorphs, basefaceMesh);
|
|
}
|
|
|
|
if (eyebrowMesh)
|
|
{
|
|
Mesh baseEyeBrowMesh = new Mesh();
|
|
eyebrowMesh.BakeMesh(baseEyeBrowMesh);
|
|
addBlendShapePart(eyebrowMesh, facial.EyeBrowMorphs, baseEyeBrowMesh);
|
|
}
|
|
|
|
if (earMesh)
|
|
{
|
|
Mesh baseEarMesh = new Mesh();
|
|
earMesh.BakeMesh(baseEarMesh);
|
|
addBlendShapePart(earMesh, facial.EarMorphs, baseEarMesh);
|
|
}
|
|
|
|
// [New] Process Mouth Mesh
|
|
if (mouthMesh)
|
|
{
|
|
Mesh baseMouthMesh = new Mesh();
|
|
mouthMesh.BakeMesh(baseMouthMesh);
|
|
addBlendShapePart(mouthMesh, facial.MouthMorphs, baseMouthMesh);
|
|
}
|
|
|
|
// [New] Process Eye Mesh
|
|
if (eyeMesh)
|
|
{
|
|
Mesh baseEyeMesh = new Mesh();
|
|
eyeMesh.BakeMesh(baseEyeMesh);
|
|
addBlendShapePart(eyeMesh, facial.EyeMorphs, baseEyeMesh);
|
|
}
|
|
|
|
facial.ClearAllWeights();
|
|
facial.ChangeMorph();
|
|
}
|
|
|
|
private static void ClearBlendShape(UmaContainerCharacter container)
|
|
{
|
|
if (!container.FaceDrivenKeyTarget) return;
|
|
//assuming base meshes do not have any blend shapes
|
|
foreach (SkinnedMeshRenderer s in container.GetComponentsInChildren<SkinnedMeshRenderer>())
|
|
{
|
|
if (s.name.Equals("M_Face") || s.name.Equals("M_Mayu") || s.name.Equals("M_Hair") ||
|
|
s.name.Equals("M_Mouth") || s.name.Equals("M_Eye"))
|
|
{
|
|
s.sharedMesh.ClearBlendShapes();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string TruncateToShiftJIS(string s, int maxBytes)
|
|
{
|
|
var encoding = System.Text.Encoding.GetEncoding("shift_jis");
|
|
if (encoding.GetByteCount(s) <= maxBytes) return s;
|
|
|
|
// 按字符逐个累计字节,避免直接截断 Shift-JIS 双字节字符产生乱码。
|
|
int charCount = 0;
|
|
int byteCount = 0;
|
|
while (charCount < s.Length)
|
|
{
|
|
int currentBytes = encoding.GetByteCount(s.Substring(charCount, 1));
|
|
if (byteCount + currentBytes > maxBytes) break;
|
|
byteCount += currentBytes;
|
|
charCount++;
|
|
}
|
|
return s.Substring(0, charCount);
|
|
}
|
|
|
|
private static string MakeUniqueShiftJisName(string sourceName, HashSet<string> usedNames, int maxBytes)
|
|
{
|
|
string candidate = TruncateToShiftJIS(sourceName, maxBytes);
|
|
if (!usedNames.Contains(candidate)) return candidate;
|
|
|
|
// 从 2 开始表示同一截断名的第 2 项;后缀始终计入 15 字节限制。
|
|
for (int duplicateIndex = 2; ; duplicateIndex++)
|
|
{
|
|
string suffix = "_" + duplicateIndex;
|
|
int prefixBytes = maxBytes - System.Text.Encoding.GetEncoding("shift_jis").GetByteCount(suffix);
|
|
candidate = TruncateToShiftJIS(sourceName, System.Math.Max(0, prefixBytes)) + suffix;
|
|
if (!usedNames.Contains(candidate)) return candidate;
|
|
}
|
|
}
|
|
|
|
private static Morph[] ReadMorph(List<Renderer> renderers)
|
|
{
|
|
Dictionary<string, List<VertexMorphData>> mergedMorphData = new Dictionary<string, List<VertexMorphData>>();
|
|
List<string> rawMorphOrder = new List<string>();
|
|
int vertexOffset = 0;
|
|
|
|
foreach (Renderer renderer in renderers)
|
|
{
|
|
Mesh mesh;
|
|
if (renderer is MeshRenderer mr)
|
|
{
|
|
var meshfilter = mr.GetComponent<MeshFilter>();
|
|
mesh = meshfilter.mesh;
|
|
}
|
|
else
|
|
{
|
|
mesh = ((SkinnedMeshRenderer)renderer).sharedMesh;
|
|
}
|
|
var vertexCount = mesh.vertexCount;
|
|
|
|
for (int i = 0; i < mesh.blendShapeCount; i++)
|
|
{
|
|
string morphName = mesh.GetBlendShapeName(i);
|
|
int frameCount = mesh.GetBlendShapeFrameCount(i);
|
|
if (frameCount <= 0)
|
|
{
|
|
// 没有任何有效帧时无法取得顶点偏移,直接跳过该 BlendShape。
|
|
continue;
|
|
}
|
|
|
|
if (!mergedMorphData.ContainsKey(morphName))
|
|
{
|
|
mergedMorphData[morphName] = new List<VertexMorphData>();
|
|
rawMorphOrder.Add(morphName);
|
|
}
|
|
|
|
var deltaVertices = new Vector3[vertexCount];
|
|
var deltaNormals = new Vector3[vertexCount];
|
|
var deltaTangents = new Vector3[vertexCount];
|
|
// Unity BlendShape 可能含多个权重帧,导出最终有效帧而不是固定读取第 0 帧。
|
|
mesh.GetBlendShapeFrameVertices(i, frameCount - 1, deltaVertices, deltaNormals, deltaTangents);
|
|
|
|
for (int j = 0; j < vertexCount; j++)
|
|
{
|
|
// 只剔除真正零位移/数值噪声,保留脸部的细微顶点变化。
|
|
if (deltaVertices[j].sqrMagnitude > MorphVertexNoiseSqrThreshold)
|
|
{
|
|
var data = new VertexMorphData();
|
|
data.VertexIndex = vertexOffset + j;
|
|
data.Offset = deltaVertices[j];
|
|
mergedMorphData[morphName].Add(data);
|
|
}
|
|
}
|
|
}
|
|
vertexOffset += vertexCount;
|
|
}
|
|
|
|
List<Morph> finalMorphs = new List<Morph>();
|
|
HashSet<string> usedMorphNames = new HashSet<string>(StringComparer.Ordinal);
|
|
Dictionary<string, Dictionary<MorphPart, List<VertexMorphData>>> mappedMorphData =
|
|
new Dictionary<string, Dictionary<MorphPart, List<VertexMorphData>>>(StringComparer.Ordinal);
|
|
List<string> mappedMorphOrder = new List<string>();
|
|
|
|
// 标准入口名必须保持精确,先预留名称;原始 Morph 若同名则使用稳定数字后缀。
|
|
HashSet<string> reservedStandardNames = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (string rawName in rawMorphOrder)
|
|
{
|
|
if (mergedMorphData[rawName].Count == 0) continue;
|
|
string mappedName = GetMappedMMDName(rawName);
|
|
if (!string.IsNullOrEmpty(mappedName)) reservedStandardNames.Add(mappedName);
|
|
}
|
|
|
|
foreach (string rawName in rawMorphOrder)
|
|
{
|
|
List<VertexMorphData> rawData = mergedMorphData[rawName];
|
|
// 空 Morph 不包含任何有效顶点,既不导出原始项,也不参与标准表情组合。
|
|
if (rawData.Count == 0) continue;
|
|
|
|
string uniqueRawName = MakeUniqueMorphName(rawName, usedMorphNames, reservedStandardNames);
|
|
MorphPart rawPart = GetMorphPart(rawName);
|
|
finalMorphs.Add(CreateVertexMorph(uniqueRawName, rawName, rawData, GetOriginalMorphCategory(rawName)));
|
|
usedMorphNames.Add(uniqueRawName);
|
|
|
|
// 将左右同部位顶点合并,但不再跨眉、眼、口直接拼成同一个 Vertex Morph。
|
|
string mmdName = GetMappedMMDName(rawName);
|
|
if (!string.IsNullOrEmpty(mmdName))
|
|
{
|
|
if (!mappedMorphData.ContainsKey(mmdName))
|
|
{
|
|
mappedMorphData[mmdName] = new Dictionary<MorphPart, List<VertexMorphData>>();
|
|
mappedMorphOrder.Add(mmdName);
|
|
}
|
|
if (!mappedMorphData[mmdName].ContainsKey(rawPart))
|
|
{
|
|
mappedMorphData[mmdName][rawPart] = new List<VertexMorphData>();
|
|
}
|
|
mappedMorphData[mmdName][rawPart].AddRange(rawData);
|
|
}
|
|
}
|
|
|
|
foreach (string mmdName in mappedMorphOrder)
|
|
{
|
|
Dictionary<MorphPart, List<VertexMorphData>> partData = mappedMorphData[mmdName];
|
|
List<VertexMorphData> bakedData = new List<VertexMorphData>();
|
|
foreach (MorphPart part in new MorphPart[] { MorphPart.Eyebrow, MorphPart.Eye, MorphPart.Mouth })
|
|
{
|
|
if (!partData.ContainsKey(part)) continue;
|
|
bakedData.AddRange(partData[part]);
|
|
|
|
// 多部位组合仍保留可单独调整的部位副本,但标准入口不再使用 Group Morph。
|
|
if (partData.Count > 1)
|
|
{
|
|
string partName = MakeUniqueMorphName(mmdName + GetMorphPartSuffix(part), usedMorphNames, reservedStandardNames);
|
|
finalMorphs.Add(CreateVertexMorph(partName, partName, MergeVertexMorphData(partData[part]), GetMorphCategory(part)));
|
|
usedMorphNames.Add(partName);
|
|
}
|
|
}
|
|
|
|
// 同一顶点可能同时受多个源表情影响,按顶点索引求和后烘焙为一个标准 Vertex Morph。
|
|
MorphPart standardPart = GetStandardMorphPart(partData);
|
|
finalMorphs.Add(CreateVertexMorph(mmdName, mmdName, MergeVertexMorphData(bakedData), GetMorphCategory(standardPart)));
|
|
usedMorphNames.Add(mmdName);
|
|
}
|
|
|
|
return finalMorphs.ToArray();
|
|
}
|
|
|
|
private static Morph CreateVertexMorph(string name, string nameEn, List<VertexMorphData> datas, MorphCategory category)
|
|
{
|
|
Morph morph = new Morph();
|
|
morph.Name = name;
|
|
morph.NameEn = nameEn;
|
|
morph.Type = MorphType.MorphTypeVertex;
|
|
morph.MorphDatas = datas.ToArray();
|
|
morph.Category = category;
|
|
return morph;
|
|
}
|
|
|
|
private static List<VertexMorphData> MergeVertexMorphData(IEnumerable<VertexMorphData> source)
|
|
{
|
|
Dictionary<int, Vector3> offsetsByVertex = new Dictionary<int, Vector3>();
|
|
foreach (VertexMorphData data in source)
|
|
{
|
|
if (offsetsByVertex.TryGetValue(data.VertexIndex, out Vector3 currentOffset))
|
|
{
|
|
offsetsByVertex[data.VertexIndex] = currentOffset + data.Offset;
|
|
}
|
|
else
|
|
{
|
|
offsetsByVertex[data.VertexIndex] = data.Offset;
|
|
}
|
|
}
|
|
|
|
List<int> sortedVertexIndices = new List<int>(offsetsByVertex.Keys);
|
|
sortedVertexIndices.Sort();
|
|
List<VertexMorphData> merged = new List<VertexMorphData>(offsetsByVertex.Count);
|
|
foreach (int vertexIndex in sortedVertexIndices)
|
|
{
|
|
Vector3 offset = offsetsByVertex[vertexIndex];
|
|
// 多源相加后若恰好抵消为零,则无需写入 PMX。
|
|
if (offset.sqrMagnitude <= MorphVertexNoiseSqrThreshold) continue;
|
|
merged.Add(new VertexMorphData { VertexIndex = vertexIndex, Offset = offset });
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
private static MorphPart GetStandardMorphPart(Dictionary<MorphPart, List<VertexMorphData>> partData)
|
|
{
|
|
// 标准名正常只属于一个 MMD 分类;兼容旧组合时按眉、眼、口优先级选择面板分类。
|
|
if (partData.ContainsKey(MorphPart.Eyebrow)) return MorphPart.Eyebrow;
|
|
if (partData.ContainsKey(MorphPart.Eye)) return MorphPart.Eye;
|
|
return MorphPart.Mouth;
|
|
}
|
|
|
|
private static MorphPart GetMorphPart(string rawName)
|
|
{
|
|
if (rawName.StartsWith("EyeBrow_", StringComparison.Ordinal) || rawName.Contains("眉"))
|
|
{
|
|
return MorphPart.Eyebrow;
|
|
}
|
|
if (rawName.StartsWith("Mouth_", StringComparison.Ordinal) || rawName.Contains("口") || rawName.Contains("Lip"))
|
|
{
|
|
return MorphPart.Mouth;
|
|
}
|
|
return MorphPart.Eye;
|
|
}
|
|
|
|
private static MorphCategory GetOriginalMorphCategory(string rawName)
|
|
{
|
|
// 原始 Morph 保持原有的按名称分类;耳部等非眉眼口项目归入 Other。
|
|
if (rawName.Contains("Mouth") || rawName.Contains("口") || rawName.Contains("Lip"))
|
|
{
|
|
return MorphCategory.MorphCatMouth;
|
|
}
|
|
if (rawName.Contains("EyeBrow") || rawName.Contains("眉"))
|
|
{
|
|
return MorphCategory.MorphCatEyebrow;
|
|
}
|
|
if (rawName.Contains("Eye") || rawName.Contains("目") || rawName.Contains("まばたき"))
|
|
{
|
|
return MorphCategory.MorphCatEye;
|
|
}
|
|
return MorphCategory.MorphCatOther;
|
|
}
|
|
|
|
private static MorphCategory GetMorphCategory(MorphPart part)
|
|
{
|
|
if (part == MorphPart.Eyebrow) return MorphCategory.MorphCatEyebrow;
|
|
if (part == MorphPart.Mouth) return MorphCategory.MorphCatMouth;
|
|
return MorphCategory.MorphCatEye;
|
|
}
|
|
|
|
private static string GetMorphPartSuffix(MorphPart part)
|
|
{
|
|
if (part == MorphPart.Eyebrow) return "_眉";
|
|
if (part == MorphPart.Mouth) return "_口";
|
|
return "_目";
|
|
}
|
|
|
|
private static string MakeUniqueMorphName(string sourceName, HashSet<string> usedNames, HashSet<string> reservedNames)
|
|
{
|
|
if (!usedNames.Contains(sourceName) && !reservedNames.Contains(sourceName)) return sourceName;
|
|
|
|
// PMX 本身不受 VMD 15 字节限制;数字后缀保证结果稳定、可预测且全局唯一。
|
|
for (int duplicateIndex = 2; ; duplicateIndex++)
|
|
{
|
|
string candidate = sourceName + "_" + duplicateIndex;
|
|
if (!usedNames.Contains(candidate) && !reservedNames.Contains(candidate)) return candidate;
|
|
}
|
|
}
|
|
|
|
private static string GetMappedMMDName(string rawName)
|
|
{
|
|
// 1. Direct Mapping
|
|
if (MorphNameMapping.TryGetValue(rawName, out string mapped)) return mapped;
|
|
|
|
// 2. Parse Unity Patterns (e.g. Eye_2_L -> Eye_CloseA -> まばたき)
|
|
|
|
// Eyes: Eye_{id}_{L/R}
|
|
if (rawName.StartsWith("Eye_"))
|
|
{
|
|
var parts = rawName.Split('_');
|
|
if (parts.Length >= 2 && int.TryParse(parts[1], out int id))
|
|
{
|
|
if (Enum.IsDefined(typeof(Gallop.Live.Cutt.LiveTimelineDefine.FacialEyeId), id))
|
|
{
|
|
string tag = Enum.GetName(typeof(Gallop.Live.Cutt.LiveTimelineDefine.FacialEyeId), id);
|
|
string key = "Eye_" + tag; // e.g. "Eye_CloseA"
|
|
// 左右 WaraiD 在正确参考模型中可精确对应传统 MMD 的左右 Wink。
|
|
if (parts.Length >= 3 && tag == "WaraiD")
|
|
{
|
|
if (parts[2].StartsWith("L", StringComparison.Ordinal)) return "ウィンク";
|
|
if (parts[2].StartsWith("R", StringComparison.Ordinal)) return "ウィンク右";
|
|
}
|
|
if (MorphNameMapping.TryGetValue(key, out string mmdName)) return mmdName;
|
|
}
|
|
}
|
|
}
|
|
// Eyebrows: EyeBrow_{id}_{L/R}
|
|
else if (rawName.StartsWith("EyeBrow_"))
|
|
{
|
|
var parts = rawName.Split('_');
|
|
if (parts.Length >= 2 && int.TryParse(parts[1], out int id))
|
|
{
|
|
if (Enum.IsDefined(typeof(Gallop.Live.Cutt.LiveTimelineDefine.FacialEyebrowId), id))
|
|
{
|
|
string tag = Enum.GetName(typeof(Gallop.Live.Cutt.LiveTimelineDefine.FacialEyebrowId), id);
|
|
string key = "EyeBrow_" + tag;
|
|
if (MorphNameMapping.TryGetValue(key, out string mmdName)) return mmdName;
|
|
}
|
|
}
|
|
}
|
|
// Mouth: Mouth_{id}_{index}
|
|
else if (rawName.StartsWith("Mouth_"))
|
|
{
|
|
var parts = rawName.Split('_');
|
|
if (parts.Length >= 2 && int.TryParse(parts[1], out int id))
|
|
{
|
|
if (Enum.IsDefined(typeof(Gallop.Live.Cutt.LiveTimelineDefine.FacialMouthId), id))
|
|
{
|
|
string tag = Enum.GetName(typeof(Gallop.Live.Cutt.LiveTimelineDefine.FacialMouthId), id);
|
|
string key = "Mouth_" + tag; // e.g. "Mouth_WaraiA"
|
|
|
|
if (MorphNameMapping.TryGetValue(key, out string mmdName)) return mmdName;
|
|
|
|
// Fallback for Mouth which sometimes had "Mouth" prefix in mapping keys or direct names?
|
|
// Existing mapping has keys like "Mouth_WaraiA". So "Mouth_" + tag is correct.
|
|
|
|
// Check for specialized suffix matches if needed, but for now tag matching is safer
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// UmaViewer Morph Name -> MMD 生态事实标准名称。
|
|
// 仅映射有参考模型或通行惯例支撑的形态,避免生成“名称正确、形状错误”的空壳标准项。
|
|
private static readonly Dictionary<string, string> MorphNameMapping = new Dictionary<string, string>()
|
|
{
|
|
// 眉
|
|
{ "EyeBrow_SeriousA", "真面目" },
|
|
{ "EyeBrow_WaraiD", "にこり" },
|
|
{ "EyeBrow_IkariA", "怒り" },
|
|
{ "EyeBrow_KanasiA", "悲しむ" },
|
|
{ "EyeBrow_KomariA", "困る" },
|
|
{ "EyeBrow_Offset_U", "上" },
|
|
{ "EyeBrow_Offset_D", "下" },
|
|
|
|
// 眼
|
|
{ "Eye_CloseA", "まばたき" }, // Blink
|
|
{ "Eye_WaraiA", "笑い" }, // Smile
|
|
{ "Eye_HalfA", "じと目" }, // Half-closed
|
|
{ "Eye_OdorokiA", "びっくり" }, // Surprised
|
|
// 参考模型顶点差异显示 KanasiA 比字面上的 IkariA 更接近传统“怒り目”。
|
|
{ "Eye_KanasiA", "怒り目" },
|
|
{ "Eye_PupilA", "瞳小" },
|
|
|
|
// 口 - 元音;大口型占用标准 VMD 名,小口型保留传统数字后缀。
|
|
{ "Mouth_TalkA_A_L", "あ" },
|
|
{ "Mouth_TalkA_I_L", "い" },
|
|
{ "Mouth_TalkA_U_L", "う" },
|
|
{ "Mouth_TalkA_E_L", "え" },
|
|
{ "Mouth_TalkA_O_L", "お" },
|
|
|
|
{ "Mouth_TalkA_A_S", "あ2" }, // Smaller versions?
|
|
{ "Mouth_TalkA_I_S", "い2" },
|
|
{ "Mouth_TalkA_U_S", "う2" },
|
|
{ "Mouth_TalkA_E_S", "え2" },
|
|
{ "Mouth_TalkA_O_S", "お2" },
|
|
|
|
// 口 - 表情。不要再与眉/眼的标准名称跨部位合并。
|
|
{ "Mouth_WaraiA", "にやり" },
|
|
{ "Mouth_WaraiB", "にっこり" },
|
|
{ "Mouth_IkariA", "怒り口" },
|
|
{ "Mouth_KomariA", "困る口" },
|
|
{ "Mouth_UreiA", "▲" }, // Triangle mouth?
|
|
{ "Mouth_OdorokiA", "□" }, // Box mouth?
|
|
};
|
|
|
|
private static Part[] ReadPartMaterials(List<Renderer> renderers, RawMMDModel model)
|
|
{
|
|
List<Part> parts = new List<Part>();
|
|
int baseShift = 0;
|
|
|
|
foreach (Renderer renderer in renderers)
|
|
{
|
|
Mesh mesh;
|
|
if (renderer is MeshRenderer mr)
|
|
{
|
|
var meshfilter = mr.GetComponent<MeshFilter>();
|
|
mesh = meshfilter.mesh;
|
|
}
|
|
else
|
|
{
|
|
mesh = ((SkinnedMeshRenderer)renderer).sharedMesh;
|
|
}
|
|
var materials = new List<Material>(renderer.sharedMaterials);
|
|
int rendererIndexCount = 0;
|
|
for (int i = 0; i < mesh.subMeshCount; i++)
|
|
{
|
|
var material = (i < materials.Count ? materials[i] : materials[materials.Count - 1]);
|
|
var part = new Part();
|
|
var mat = new MMDMaterial();
|
|
part.Material = mat;
|
|
mat.Name = mat.NameEn = material.name.Replace(" (Instance)","");
|
|
mat.DiffuseColor = Color.white;
|
|
mat.SpecularColor = Color.clear;
|
|
mat.AmbientColor = Color.white * 0.5f;
|
|
mat.Shiness = 5;
|
|
mat.CastSelfShadow = true;
|
|
mat.DrawGroundShadow = true;
|
|
mat.DrawSelfShadow = true;
|
|
mat.EdgeColor = Color.black;
|
|
mat.EdgeSize = 0.4f;
|
|
if (material.HasProperty("_MainTex") && material.mainTexture != null)
|
|
{
|
|
var tex = model.TextureList.Find(t => t.TexturePath.Contains($"/{material.mainTexture.name}.png"));
|
|
mat.Texture = (tex ?? model.TextureList[0]);
|
|
}
|
|
else
|
|
{
|
|
mat.Texture = model.TextureList[0];
|
|
}
|
|
mat.MetaInfo = "";
|
|
var subMesh = mesh.GetSubMesh(i);
|
|
part.BaseShift = baseShift + subMesh.indexStart;
|
|
part.TriangleIndexNum = subMesh.indexCount;
|
|
rendererIndexCount += subMesh.indexCount;
|
|
parts.Add(part);
|
|
}
|
|
baseShift += rendererIndexCount;
|
|
}
|
|
return parts.ToArray();
|
|
}
|
|
|
|
private static Vertex[] ReadVerticesAndTriangles(List<Renderer> renderers, Dictionary<Transform, int> boneIndexes, ref List<int> triangleList, Transform root)
|
|
{
|
|
List<Vertex> verticesList = new List<Vertex>();
|
|
int vertexOffset = 0;
|
|
|
|
foreach (Renderer renderer in renderers)
|
|
{
|
|
if (renderer is MeshRenderer mr)
|
|
{
|
|
var meshfilter = mr.GetComponent<MeshFilter>();
|
|
//Cache this data to avoid copying arrays.
|
|
var mesh = meshfilter.mesh;
|
|
if (!mesh.isReadable)
|
|
{
|
|
UmaViewerUI.Instance.ShowMessage($"Mesh {mesh.name} was not readable. Attempting to export", UIMessageType.Warning);
|
|
try
|
|
{
|
|
mesh = MakeReadableMeshCopy(mesh);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException($"Cannot export unreadable mesh {mesh.name} on {renderer.name}", ex);
|
|
}
|
|
}
|
|
var vertices = mesh.vertices;
|
|
var normals = mesh.normals;
|
|
var uv = mesh.uv;
|
|
var uv1 = mesh.uv2;
|
|
var uv2 = mesh.uv3;
|
|
var colors = mesh.colors;
|
|
var triangles = mesh.triangles;
|
|
|
|
for (int i = 0; i < vertices.Length; i++)
|
|
{
|
|
Vertex vertex = new Vertex();
|
|
vertex.Coordinate = renderer.transform.TransformPoint(vertices[i]);
|
|
vertex.Normal = normals[i];
|
|
vertex.UvCoordinate = new Vector2(uv[i].x, 1 - uv[i].y);
|
|
|
|
vertex.ExtraUvCoordinate = new Vector4[3]
|
|
{
|
|
uv1.Length > 0 ? new Vector2(uv1[i].x, 1 - uv1[i].y) : Vector2.zero,
|
|
uv2.Length > 0 ? new Vector2(uv2[i].x, 1 - uv2[i].y) : Vector2.zero,
|
|
colors.Length > 0 ? colors[i] : Color.clear
|
|
};
|
|
|
|
vertex.SkinningOperator = new SkinningOperator() { Type = SkinningType.SkinningBdef1 };
|
|
vertex.SkinningOperator.Param = new Bdef1() { BoneId = GetBoneIndex(boneIndexes, renderer.transform) };
|
|
vertex.EdgeScale = 1;
|
|
verticesList.Add(vertex);
|
|
}
|
|
|
|
|
|
foreach (var triangle in mesh.triangles)
|
|
{
|
|
triangleList.Add(triangle + vertexOffset);
|
|
}
|
|
vertexOffset += vertices.Length;
|
|
}
|
|
else if (renderer is SkinnedMeshRenderer smr)
|
|
{
|
|
var skinMesh = smr.sharedMesh;
|
|
var mesh = skinMesh;
|
|
if (!mesh.isReadable)
|
|
{
|
|
UmaViewerUI.Instance.ShowMessage($"Mesh {mesh.name} was not readable. Attempting to export", UIMessageType.Warning);
|
|
try
|
|
{
|
|
mesh = MakeReadableMeshCopy(mesh);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException($"Cannot export unreadable skinned mesh {mesh.name} on {renderer.name}", ex);
|
|
}
|
|
}
|
|
var vertices = mesh.vertices;
|
|
var normals = mesh.normals;
|
|
var uv = mesh.uv;
|
|
var uv1 = mesh.uv2;
|
|
var uv2 = mesh.uv3;
|
|
var colors = mesh.colors;
|
|
// Geometry may need a GPU-readable copy, but that copy has no valid skinning data.
|
|
var boneCounts = skinMesh.GetBonesPerVertex();
|
|
var skinbone = smr.bones;
|
|
var skinbones = smr.bones;
|
|
var weights = skinMesh.boneWeights;
|
|
var bakemesh = new Mesh();
|
|
smr.BakeMesh(bakemesh,true);
|
|
|
|
for (int i = 0; i < vertices.Length; i++)
|
|
{
|
|
Vertex vertex = new Vertex();
|
|
vertex.Coordinate = root.InverseTransformPoint(smr.transform.TransformPoint(bakemesh.vertices[i]));
|
|
vertex.Normal = normals[i];
|
|
vertex.UvCoordinate = new Vector2(uv[i].x, 1 - uv[i].y);
|
|
vertex.ExtraUvCoordinate = new Vector4[3]
|
|
{
|
|
uv1.Length > 0 ? new Vector2(uv1[i].x, 1 - uv1[i].y) : Vector2.zero,
|
|
uv2.Length > 0 ? new Vector2(uv2[i].x, 1 - uv2[i].y) : Vector2.zero,
|
|
colors.Length > 0 ? colors[i] : Color.clear
|
|
};
|
|
|
|
var boneWeight = weights[i];
|
|
var boneCount = boneCounts[i];
|
|
|
|
switch (boneCount)
|
|
{
|
|
case 0:
|
|
vertex.SkinningOperator = new SkinningOperator() { Type = SkinningType.SkinningBdef1 };
|
|
vertex.SkinningOperator.Param = new Bdef1() { BoneId = GetBoneIndex(boneIndexes, renderer.transform) };
|
|
break;
|
|
|
|
default:
|
|
case 1:
|
|
vertex.SkinningOperator = new SkinningOperator() { Type = SkinningType.SkinningBdef1 };
|
|
vertex.SkinningOperator.Param = new Bdef1() { BoneId = GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex0]) };
|
|
break;
|
|
|
|
case 2:
|
|
vertex.SkinningOperator = new SkinningOperator() { Type = SkinningType.SkinningBdef2 };
|
|
vertex.SkinningOperator.Param = new Bdef2()
|
|
{
|
|
BoneId = new int[]{
|
|
GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex0]),
|
|
GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex1]),
|
|
},
|
|
BoneWeight = boneWeight.weight0
|
|
};
|
|
break;
|
|
|
|
case 3:
|
|
case 4:
|
|
vertex.SkinningOperator = new SkinningOperator() { Type = SkinningType.SkinningBdef4 };
|
|
vertex.SkinningOperator.Param = new Bdef4()
|
|
{
|
|
BoneId = new int[]{
|
|
GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex0]),
|
|
GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex1]),
|
|
GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex2]),
|
|
GetBoneIndex(boneIndexes, skinbone[boneWeight.boneIndex3]),
|
|
},
|
|
BoneWeight = new float[]
|
|
{
|
|
boneWeight.weight0,
|
|
boneWeight.weight1,
|
|
boneWeight.weight2,
|
|
boneWeight.weight3
|
|
}
|
|
};
|
|
break;
|
|
}
|
|
vertex.EdgeScale = 1;
|
|
verticesList.Add(vertex);
|
|
}
|
|
|
|
foreach (var triangle in mesh.triangles)
|
|
{
|
|
triangleList.Add(triangle + vertexOffset);
|
|
}
|
|
vertexOffset += vertices.Length;
|
|
}
|
|
}
|
|
return verticesList.ToArray();
|
|
}
|
|
|
|
private static int GetBoneIndex(Dictionary<Transform, int> boneIndexes, Transform bone)
|
|
{
|
|
// 被过滤骨的权重转交给最近的有效祖先,保证索引始终指向实际 PMX 骨骼。
|
|
Transform current = bone;
|
|
while (current != null)
|
|
{
|
|
if (boneIndexes.TryGetValue(current, out int index))
|
|
{
|
|
return index;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
|
|
// 兜底到「全ての親」。
|
|
return 0;
|
|
}
|
|
|
|
/// <summary> Credit to pohype: https://discussions.unity.com/t/reading-meshes-at-runtime-that-are-not-enabled-for-read-write/804189/7 </summary>
|
|
public static Mesh MakeReadableMeshCopy(Mesh nonReadableMesh)
|
|
{
|
|
Mesh meshCopy = new Mesh();
|
|
meshCopy.indexFormat = nonReadableMesh.indexFormat;
|
|
|
|
// Handle vertices
|
|
GraphicsBuffer verticesBuffer = nonReadableMesh.GetVertexBuffer(0);
|
|
int totalSize = verticesBuffer.stride * verticesBuffer.count;
|
|
byte[] data = new byte[totalSize];
|
|
verticesBuffer.GetData(data);
|
|
meshCopy.SetVertexBufferParams(nonReadableMesh.vertexCount, nonReadableMesh.GetVertexAttributes());
|
|
meshCopy.SetVertexBufferData(data, 0, 0, totalSize);
|
|
verticesBuffer.Release();
|
|
|
|
// Handle triangles
|
|
meshCopy.subMeshCount = nonReadableMesh.subMeshCount;
|
|
GraphicsBuffer indexesBuffer = nonReadableMesh.GetIndexBuffer();
|
|
int tot = indexesBuffer.stride * indexesBuffer.count;
|
|
byte[] indexesData = new byte[tot];
|
|
indexesBuffer.GetData(indexesData);
|
|
meshCopy.SetIndexBufferParams(indexesBuffer.count, nonReadableMesh.indexFormat);
|
|
meshCopy.SetIndexBufferData(indexesData, 0, 0, tot);
|
|
indexesBuffer.Release();
|
|
|
|
// Restore submesh structure
|
|
uint currentIndexOffset = 0;
|
|
for (int i = 0; i < meshCopy.subMeshCount; i++)
|
|
{
|
|
uint subMeshIndexCount = nonReadableMesh.GetIndexCount(i);
|
|
meshCopy.SetSubMesh(i, new SubMeshDescriptor((int)currentIndexOffset, (int)subMeshIndexCount));
|
|
currentIndexOffset += subMeshIndexCount;
|
|
}
|
|
|
|
// Recalculate normals and bounds
|
|
meshCopy.RecalculateNormals();
|
|
meshCopy.RecalculateBounds();
|
|
|
|
return meshCopy;
|
|
}
|
|
}
|