基于 Cocos Creator 3.0 的 3D 换装

无论是 2D 或是 3D 游戏,换装类都是比较受欢迎的游戏,也是游戏开发者经常需要面对的开发需求。

本文主要介绍 3D 换装需求,关于 2D 换装(Spine 或龙骨)后续会向大家介绍。

本周六羽毛先生也将出席 Cocos Star Meetings 广州站,为大家带来《3D 项目经验分享》,欢迎大家到现场一起快乐交流玩耍~

需求

  1. 从换装的方式分类,可以分为整体换装以及局部换装。整体换装较为简单,我们就不做讨论,本文主要介绍一下局部换装(其实理解过后也是非常的简单)。

  2. 从换装的模型分类, 主要分为两种类型:

  • 一种类型是对于静态模型的换装,就是直接将身体需要换的 Mesh 更新即可。

  • 另一种类型是动态模型的换装(有动作的模型)。

本文主要介绍动态模型的换装实现。

效果展示

f94eeb46de5322ab054b0adba3efe48f.gif(素材仅用于学习交流)

原理介绍

在开始描述换装前,首先要具备骨骼动画的知识。

如果对骨骼动画的原理不熟悉,换装是比较难以理解的。换装的核心其实并不在换上,而是要理解为什么能换,而这些都和骨骼动画密不可分。

骨骼动画的组成:

d518f9d151d8bcce9e72870c94d2e05d.jpeg

图 1(引用于 Shader 实验室)

  • 网格(Mesh):

    模型(Model)是由一个个三角形组成的,而这种三角形的学名则是网格(Mesh)

  • 网格蒙皮数据(Skin Info)

    顶点的 Skin 数据包括顶点受哪些骨骼影响以及这些骨骼影响该顶点时的权重(Weight),另外对于每块骨骼还需要骨骼偏移矩阵(BoneOffsetMatrix)用来将顶点从Mesh空间变换到骨骼空间。可简单理解为:SkinMesh = Mesh+Skin Info

  • 骨骼(Skeleton):

    如图 1,骨架由一系列具有层次关系的关节(骨骼)和关节链组成,是一种树结构,选择其中一个是根关节,其它关节是根关节的子孙,可以通过平移和旋转根关节移动,并确定整个骨架在世界空间中的位置和方向。

  • 骨骼的动画(关键帧)数据

骨骼动画是通过关键帧驱动骨骼运动,随之依次调整每块骨头的朝向和坐标,骨头再带动顶点运动,蒙皮信息描述了每个顶点受哪些骨头的影响,以及他们的权重,这样骨骼动画就实现了运动以及形变。

实现思路

导入模型进入 Creator,可发现节点下含有 SkinnedMeshRenderer 组件,其中含有 Mesh 属性,按照我的理解这里的 Mesh 特指 SkinMesh = Mesh+Skin Info,而非普通的静态 Mesh。

动态模型换装需要更新 SkinnedMeshRenderer 组件的中 SkinMesh,Skeleton(骨骼资源), SkinningRoot(骨骼根节点的引用——控制此模型的动画组件所在节点)。

本案例中采取直接更换蒙皮网格渲染器组件(SkinnedMeshRenderer)的方式实现换装。

实现步骤

  1. 骨骼动画及部位装备 Prefab 的制作,核心——共享一套骨骼。动画师制作时,同一部位的不同装备绑定同一根骨骼,整体输出,在 Creator 中将各部件装备制作为 Prefab 后从主角删除,主角只保留一套默认装备。

39ed1a50c7906fac44cd9cddea2f1205.png

  1. 主角节点需要关闭预烘焙功能,否则无法实时运算以实现换装功能。48c9f600f4485de940f1b35023dcb789.png

  2. 初始化模型。建立 Map<key-PartName, value-Node>,这一步是为了后续替换装备时可以检索到对应部位的节点。

  3. 替换装备节点:

  • 删除旧装备节点。检索 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]
    }
}

小结

换装的核心是要理解为什么能换,理解了骨骼动画的原理以及构成,一旦弄清“为什么”?,换装的实现就会是非常简单的一件事了。

如果羽毛的理解存在错误,欢迎回复进行指导。

往期精彩

5136464f61d8e93fe9a0480915fb0584.jpeg

8ef5644ee80133669b2ea9aa955136ba.jpeg

76dd5b4ba9c53d27be0f48cc2587c724.jpeg

Cocos Creator模拟砸金蛋3d旋转效果 | 附代码egg.zip // Learn TypeScript: // - [Chinese] https://docs.cocos.com/creator/manual/zh/scripting/typescript.html // - [English] http://www.cocos2d-x.org/docs/creator/manual/en/scripting/typescript.html // Learn Attribute: // - [Chinese] https://docs.cocos.com/creator/manual/zh/scripting/reference/attributes.html // - [English] http://www.cocos2d-x.org/docs/creator/manual/en/scripting/reference/attributes.html // Learn life-cycle callbacks: // - [Chinese] https://docs.cocos.com/creator/manual/zh/scripting/life-cycle-callbacks.html // - [English] http://www.cocos2d-x.org/docs/creator/manual/en/scripting/life-cycle-callbacks.html const {ccclass, property} = cc._decorator; @ccclass export default class Game extends cc.Component { @property Count: number = 5; @property(cc.Prefab) prefab: cc.Prefab = null; @property(cc.Node) nodeParent: cc.Node = null; private mEggs: cc.Node[] = []; // LIFE-CYCLE CALLBACKS: // onLoad () {} start () { } // update (dt) {} onClick(event, data){ switch(data){ case 'add':{ this.addEggs(); break; } case 'move':{ this.moveEggs(); break; } case 'stop':{ this.stopMoveEggs(); break; } } } addEggs(){ if(this.Count <= 0){ return; } this.mEggs = []; const N = 360 / this.Count; for(let i = 0; i < this.Count; i++){ let egg = cc.instantiate(this.prefab); let js = egg.getComponent('Egg'); js.setRadian(i * N * Math.PI / 180); js.updatePos(); egg.parent = this.nodeParent; this.mEggs.push(egg); } } moveEggs(){ for(let i = 0; i < this.mEggs.length; i++){ this.mEggs[i].getComponent('Egg').setMove(true); } } stopMoveEggs(){ for(let i = 0; i < this.mEggs.length; i++){ this.mEggs[i].getComponent('Egg').setMove(false); } } }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值