基于 Cocos Creator 3.0 的 3D 换装
需求
从换装的方式分类,可以分为整体换装以及局部换装。整体换装较为简单,我们就不做讨论,本文主要介绍一下局部换装(其实理解过后也是非常的简单)。
从换装的模型分类, 主要分为两种类型:
一种类型是对于静态模型的换装,就是直接将身体需要换的 Mesh 更新即可。
另一种类型是动态模型的换装(有动作的模型)。
效果展示
原理介绍
网格(Mesh):
模型(Model)是由一个个三角形组成的,而这种三角形的学名则是网格(Mesh)
网格蒙皮数据(Skin Info)
顶点的 Skin 数据包括顶点受哪些骨骼影响以及这些骨骼影响该顶点时的权重(Weight),另外对于每块骨骼还需要骨骼偏移矩阵(BoneOffsetMatrix)用来将顶点从Mesh空间变换到骨骼空间。可简单理解为:SkinMesh = Mesh+Skin Info
骨骼(Skeleton):
如图 1,骨架由一系列具有层次关系的关节(骨骼)和关节链组成,是一种树结构,选择其中一个是根关节,其它关节是根关节的子孙,可以通过平移和旋转根关节移动,并确定整个骨架在世界空间中的位置和方向。
骨骼的动画(关键帧)数据
实现思路
实现步骤
骨骼动画及部位装备 Prefab 的制作,核心——共享一套骨骼。动画师制作时,同一部位的不同装备绑定同一根骨骼,整体输出,在 Creator 中将各部件装备制作为 Prefab 后从主角删除,主角只保留一套默认装备。
主角节点需要关闭预烘焙功能,否则无法实时运算以实现换装功能。
初始化模型。建立 Map<key-PartName, value-Node>,这一步是为了后续替换装备时可以检索到对应部位的节点。
替换装备节点:
删除旧装备节点。检索 Map,根据部位 key-PartName 获得 OldNode 引用,移除 OldNode(保留骨骼根节点引用 SkinningRoot,后续备用)。 增加新装备节点,加载部位 A 新装备 Prefab 并实例化为 NewNode,添加 NewNode。 刷新部位 key-PartName 的 value 值为 NewNode。 刷新骨骼,取得步骤 1 中的 SkinningRoot 来刷新 NewNode 的 SkinningRoot,完成(我实现到这步,后续步骤为了节省性能大家可以研究)。
合并 Mesh。
合并贴图(贴图的宽高最好是 2 的 N 次方的值)。
重新计算 UV。
核心代码
import { _decorator, Component, Node, resources, Prefab, instantiate, SkinnedMeshRenderer, EventTouch, SkeletalAnimation } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ChangeCloth')
export class ChangeCloth extends Component {
@property({
type: Node
})
modelNode!: Node;
sex: string = "male";
bodyPart: string[] = ["hair", "top", "pants", "shoes"];
data: Map<string, Node> = new Map();
start() {
this.initAllData();
}
initAllData() {
this.data.clear();
for (let i = 0; i < this.bodyPart.length; i++) {
let partName = this.bodyPart[i];
let nodeName = `${this.sex}_${partName}-1`;
let nodePart = this.modelNode.getChildByName(nodeName);
if (nodePart) {
console.debug("init part", nodeName);
this.data.set(partName, nodePart);
}
}
}
changeCloth(partName: string, index: number) {
resources.load(`prefab/${this.sex}_${partName}-${index}`, Prefab, (err, prefab) => {
if (err) {
console.debug(err);
return;
}
let oldNode = this.data.get(partName);
let oldModel = oldNode?.getComponent(SkinnedMeshRenderer);
let newNode = instantiate(prefab);
let newModel = newNode.getComponent(SkinnedMeshRenderer);
if (oldModel?.skinningRoot && newModel) {
newModel.skinningRoot = oldModel?.skinningRoot;
oldNode?.removeFromParent();
this.modelNode.addChild(newNode);
this.data.set(partName, newNode);
}
})
}
onClickChange(touch: EventTouch, data: string) {
console.debug("onClickChange", data);
let params = data.split("-");
this.changeCloth(params[0], parseInt(params[1]));
}
onClickAnimation(touch: EventTouch, animationName: string) {
console.debug("onClickAnimation", animationName);
this.modelNode.getComponent(SkeletalAnimation)!.play(animationName);
}
update(deltaTime: number) {
// [4]
}
}