3D 桌面精灵制作的探索
我对于 3D 桌面精灵的制作思路,与 2D 的并没有什么区别。都是通过程序对骨骼进行控制。
难点在于,对于 2D桌面精灵,我们可以通过简单的矢量计算来控制骨骼,达到较好的效果。而对于 3D的角色,其骨骼运动就牵扯到人体运动学及一系列复杂的矢量计算和物理模拟。
首先,我先列举一下在 Unity 中通过程序对骨骼进行操作的一些方法:
MonoBehaviour 的 OnAnimatorIK:
MonoBehaviour 拥有的 OnAnimatorIK 函数
该函数会在脚本挂载的物体的 Animator 计算完骨骼后被调用,在这里我们可以通过 animator 对象对骨骼进行旋转微调。
我们可以通过 Animator.SetBoneLocalRotation 来对骨骼进行旋转
注意: 只有当动画状态机,状态层级(Layer) 的 IKPass 开启后,OnAnimatorIK 才会被调用。
开启 IK Pass
使用该函数可以对骨骼进行微调,但是如果要控制多个骨骼做一些较复杂操作则比较困难。
StateMachineBehaviour 的 OnStateIK 函数:
OnStateIK
StateMachineBehaviour 是可以挂载在 动画状态机中各个状态上的脚本:
这里的 OnStateUpdate 就是挂载到动画状态机的POSE01状态上的一个 StateMachineBehaviour 脚本
OnStateIK 函数 其实与 OnAnimatorIK 是一样的,我们可以通过Animator.SetBoneLocalRotation 来对骨骼进行微调。
在 LateUpdate 中直接控制骨骼
LateUpdate
LateUpdate 会在所有动画计算结束后被调用,此时由于 动画计算已经结束,Animator 实际上在这时候对于角色的骨骼的控制权已经释放掉了。此时,我们可以直接摆脱 Animator 的限制,直接对角色的骨骼进行各种操作,这些操作会覆盖掉之前动画计算的结果。
显然,在 LateUpdate 中对骨骼进行控制会更加的方便和多样。因此,我最终选择将对骨骼的控制代码写在 LateUpdate 中。
我一开的思路是在角色动画的基础上控制骨骼,但实际操作中,总会出现骨骼抖动的情况。所以,我最后放弃了在动画基础上控制骨骼,改为直接控制骨骼。我们角色身上虽然挂载了 Animator 组件,但是其 AnimationController 的引用是空的:
Animator 组件
在选定了骨骼控制代码的调用函数或者说声明周期后,我开始试着在 LateUpdate 中编写骨骼控制代码。但是要做到通过代码控制 手掌、手臂、胳膊、肩膀等骨骼的统一有机地运动则相当的复杂与困难。
正当我一筹莫展时,突然想起了 Unity 内置的 “布娃娃系统”。
布娃娃系统介绍
对于比较了解游戏相关各种技术的朋友应该对 “布娃娃系统” 并不陌生。“布娃娃系统” 就是一套专门用来模拟人体或者生物的各组织关节间物理规则与物理运动的系统。
“布娃娃系统” 最常见的用途就是游戏角色死亡时和死亡后对于尸体的物理模拟。经典的例子就是守望先锋:
铁拳的尸体
Unity 内置的布娃娃系统
要是有 Unity 的 布娃娃系统,我们需要在 Unity 最上方的工具栏中 点击 GanmeObject -> 3D Object -> Ragdoll... 这样就可以打开布娃娃系统窗口:
打开方法
布娃娃创建窗口
在窗口中可以看到我们需要指定一些特定的骨骼,把我们角色物体对应的骨骼放进去,然后点击 Create 即可。
我对Unity酱的骨骼和Ragdoll 的对应
运行效果:
游戏运行后,Unity酱的身体由于重力作用倾斜倒地
可以看到,使用了 “布娃娃系统” 后,当角色身体有一个关节发生了运动,其它各个部位都会随之产生影响。这样我们就不需要自己去模拟物理运动了,我们可以只指定一个部位的运动,而其它部位的运动会被“布娃娃系统”所进行运算。
但这样也会带来一些问题:
所以,我们还需要再对 “布娃娃系统” 进行分析。
Unity 布娃娃系统 浅析
创建出布娃娃系统后,观察角色的骨骼会发现,布娃娃系统是在角色的一些关键骨骼上创建 Rigidbody、Capsule Collider 和 Character Joint 这三个组件来构造的。
Rigidbody 负责对骨骼的运动进行物理运算、Capsule Collider 则是该部位的碰撞器,放在各个部位之间产生明显的穿模或者部位与场景之间产生明显穿模、Character Joint 负责计算该骨骼 和 与其连接的骨骼 之间的相互物理作用。
因此,我们可以自己在一些骨骼上添加这三个组件,来实现一个 “局部布娃娃系统”
介绍到此结束。接下来,开始我们 3D桌面精灵 的正式制作。
3D 桌面精灵的制作
项目创建
我们创建一个 Unity 默认的 3D 项目。 因为待会儿我们要用 Unity酱 来作为我们的桌面精灵。而 Unity酱 的 Shader 是不支持通用渲染管线,并且也无法升级为通用渲染管线,所以我们使用默认的3D项目。
资源导入
所用资源也非常少,首先肯定是把 Unity酱 导入进来啦。 我们可以在 Unity 的 Assets Store 上搜索 Unity Chan,然后进行免费的下载和导入。
UnityChan 的 AssetStore界面
然后就是导入制作好的键盘模型。这里推荐一个网站:CGTrader 一个供创作者分享、售卖3D模型的网站,里面有非常丰富的3D模型资源,有收费的也有免费的。我就直接在这里找了一个免费的键盘模型。
网址:https://www.cgtrader.com/
网站截图
后面我还会用到粒子系统,这里的粒子系统我用的是 Visual Effect Graph,因为 Visual Effect Graph 比 Unity 传统的粒子系统做起来更方便,效果也很好,并且支持默认渲染管线。
直接在 PackageManager 中安装 Visual Effect Graph 即可
然后为了制作粒子特效,我还需要些 字母 的 3D网格,所以又找来一些 模型:
字母模型
最后我还希望能有一些后处理,因此导入后处理套件。因为是默认渲染管线,所以直接在 PackageManager 中导入 PostProcess 套件:
Post Processing 套件
场景搭建
场景搭建也十分简单。
首先在 Unity chan 的 Prefabs 文件夹中找到 unitychan_dynamic 作为我们的桌面精灵角色。
之所以选择 unitychan_dynamic 是因为 其挂载的脚本实现了人物眨眼 和 布料、头发随风摆动的效果。其实我们也可以使用 Unity 的 Cloth 组件 和 Wind Zone 来实现布料和头发的摆动,但是还要去设置一些参数,既然 unitychan_dynamic 已经具有这些功能了,那直接使用就好了。
我们创建一个 Cube 作为 Unity酱 的椅子,然后把 Unity酱 摆放上去,再把键盘模型摆上去:
摆放
光源与摄像机的摆放
Unity Chan 设置
这里我们禁用掉 Unity酱 身上的一些脚本,并且把 Animator 的 Controller 设为 None 因为我们并不需要这些功能:
Unity酱的Inspector面板
接下来设置我们的 “局部布娃娃系统”,因为现在笔者只做了左半边的效果,右半边关于鼠标的响应还没做,所以文章只讲述左半边的制作过程,实际上右半边与左半边在原理上并无区别。
首先在 Unity酱 的 Spine2 骨骼上创建 Rigidbody 和 Capsule Collider 组件,我们将用该骨骼作为“局部布娃娃系统”的根节点,作为根节点,该骨骼应当保持静止状态,所以我们让刚体组件勾选 Is Kinematic, 另外对于骨骼而言,骨骼的正前方朝向是 自身X轴方向,所以 Collider 的 Direction 应该设置为 X-Axis:
Spine2 的 刚体 和 碰撞体组件
Spine2 的碰撞体包裹了角色的主干身体
接下来在 RightShoulder 骨骼上 挂载 Rigidbody 和 Character Joint 组件, Character Joint 的 Connected Body 设置为 Spine2 骨骼:
Rigidbody 和 Character Joint 组件
接着在 RightArm 骨骼同样 挂载 Rigidbody 和 Character Joint 组件,Connected Body 设置为 RightShoulder 骨骼,并且因为 Arm 会有比较大的移动幅度,为了防止其与身体的穿模,所以在骨骼上再添加一个 Capsule Collider:
Rigidbody、Capsule Collider 和 Character Joint
Arm 的碰撞体
继续顺着胳膊走,来到手臂骨骼 RightForceArm,同样添加 Rigidbody 和 Character Joint,Connected Body 选择 RightArm:
RightForceArm
顺着手臂来到了我们的手掌:
RightHand
至此,我们的 "局部布娃娃系统" 就搞定了。接下来进入代码部分。
代码部分
首先是 hook 实现窗体透明 和 hook 实现后台检测按键输入。这一部分与 2D桌面精灵文章中使用的代码没有什么区别可以使用。
唯一的区别在于,之前可以注册的键盘回调函数只能是 Action 类型,而这里我又添加了一种 Action<KeyCode> 类型,即当我们按下键盘时,可以把按下的键盘名传入到回调函数中,让回调函数自身去判断然后再做进一步处理。之前是检测到按键后对按键名进行判断和分析再调用对应回调函数,这种方式现在仍有保留。
这里把修改后的 KeyCodeController 和 MPlayerInput 脚本给出,如果已经理解了 讲解2D桌面精灵时给出的那两个脚本,那么对于里面做出的修改应该不难理解。
KeyCodeController脚本:
using System.Collections; using System.Collections.Generic; using UnityEngine; using System; using System.Runtime.InteropServices; using System.Diagnostics; public class KeyCodeController : MonoBehaviour { //截获按钮 private const int WH_KEYBOARD_LL = 13; private const int WM_KEYDOWN = 0x0100; private const int WM_KEYUP = 0x0101; private const int WM_SYSKEYDOWN = 0x0104; private const int WM_SYSKEYUP = 0x0105; private static LowLevelKeyboardProc _proc = HookCallback; private static IntPtr _hookID = IntPtr.Zero; private static Dictionary<KeyCode, bool> keyDownDic = new Dictionary<KeyCode, bool>(); // Use this for initialization void Start() { _hookID = SetHook(_proc); } void OnApplicationQuit() { UnhookWindowsHookEx(_hookID); } private static IntPtr SetHook(LowLevelKeyboardProc proc) { using (Process curProcess = Process.GetCurrentProcess()) using (ProcessModule curModule = curProcess.MainModule) { return SetWindowsHookEx(WH_KEYBOARD_LL, proc, GetModuleHandle(curModule.ModuleName), 0); } } private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam); private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { int vkCode = Marshal.ReadInt32(lParam); KeyCode key = KeyCode.None; try { key = (KeyCode)(vkCode + 32); } catch { } if (nCode >= 0 && wParam == (IntPtr)WM_KEYDOWN) { key = (KeyCode)(vkCode + 32); if(!keyDownDic.ContainsKey(key) || keyDownDic[key] == false) { MPlayerInput.Single.KeyDownCallBack(key); keyDownDic[key] = true; } } if (nCode >= 0 && wParam == (IntPtr)WM_KEYUP) { key = (KeyCode)(vkCode + 32); keyDownDic[key] = false; MPlayerInput.Single.KeyUpCallBack(key); } return CallNextHookEx(_hookID, nCode, wParam, lParam); } [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool UnhookWindowsHookEx(IntPtr hhk); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr GetModuleHandle(string lpModuleName); }
MPlayerInput 脚本:
using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public delegate void KeyDownCallBack(KeyCode key); public delegate void KeyUpCallBack(KeyCode key); public class MPlayerInput : MonoBehaviour { public static MPlayerInput Single; private Dictionary<KeyCode, Action> keyDownDic = new Dictionary<KeyCode, Action>(); private Dictionary<KeyCode, Action> keyUpDic = new Dictionary<KeyCode, Action>(); private List<KeyDownCallBack> keyDownSelfKeyList = new List<KeyDownCallBack>(); private List<KeyUpCallBack> keyUpSelfKeyList = new List<KeyUpCallBack>(); private Action<Vector3> mouseMoveList = (movement) => { }; private Action mouseEventCall = () => { }; private Action mouseClickCall = () => { }; private Action mouseReleaseCall = () => { }; private void Awake() { if(Single != null) { Destroy(Single.gameObject); } Single = this; } public void RegisterMouseMoveCallBack(Action<Vector3> callBack) { mouseMoveList += callBack; } public void RegisterMouseEventCallBack(Action callBack) { mouseEventCall += callBack; } public void RegisterMouseClickCallBack(Action callBack) { mouseClickCall += callBack; } public void RegisterMouseRelaeaseCallBack(Action callBack) { mouseReleaseCall += callBack; } public void MouseMoveCallBack(Vector3 movement) { mouseMoveList.Invoke(movement); } public void MouseEventCallBack() { mouseEventCall.Invoke(); } public void MouseClickCallBack() { mouseClickCall.Invoke(); } public void MouseReleaseCallBack() { mouseReleaseCall.Invoke(); } public void RegisterKeyDownCallBack(KeyCode key, Action callBack) { if (!keyDownDic.ContainsKey(key)) keyDownDic[key] = callBack; else keyDownDic[key] += callBack; } public void RegisterKeyDownCallBackSelfKey(KeyDownCallBack callBack) { keyDownSelfKeyList.Add(callBack); } public void RegisterKeyUpCallBack(KeyCode key, Action callBack) { if (!keyUpDic.ContainsKey(key)) keyUpDic[key] = callBack; else keyUpDic[key] += callBack; } public void RegisterKeyUpCallBackSelfKey(KeyUpCallBack callBack) { keyUpSelfKeyList.Add(callBack); } public void KeyDownCallBack(KeyCode key) { if(keyDownDic.ContainsKey(key)) keyDownDic[key].Invoke(); KeyDownCallBackSelfKey(key); } public void KeyUpCallBack(KeyCode key) { if (keyUpDic.ContainsKey(key)) keyUpDic[key].Invoke(); KeyUpCallBackSelfKey(key); } private void KeyDownCallBackSelfKey(KeyCode key) { for(int i = 0; i < keyDownSelfKeyList.Count; ++i) { keyDownSelfKeyList[i].Invoke(key); } } private void KeyUpCallBackSelfKey(KeyCode key) { for (int i = 0; i < keyUpSelfKeyList.Count; ++i) { keyUpSelfKeyList[i].Invoke(key); } } }
WindowSetting 和 MouseController 脚本是没有变化的。
我们把这些脚本挂载到场景物体上,这里我仍是挂载到了主摄像机上:
主摄像机
接下来就是我们控制桌面精灵的脚本了。
创建 Character Controller 脚本:
using System.Collections; using System.Collections.Generic; using UnityEngine; public class CharacterController : MonoBehaviour { // 存储所有键盘按键位置的根物体 // 所有的键盘按键都作为该物体的子物体 [SerializeField] private Transform keyBoardRoot = null; // 存储 按键名 和 KeyBoard对象 构成的字典表 // 每个按键物体身上都会挂载一个 KeyBoard 对象 private Dictionary<string, KeyBoard> keyBoadDic = new Dictionary<string, KeyBoard>(); // 角色的 animator private Animator animator; // PlayeInput private MPlayerInput playerInput; // 当前手掌要移动到的位置 private Transform target; // 手掌的引用 private Transform rightHand; private Rigidbody rightHandRig; private void Awake() { // 初始化,获取所有按键物体身上的 KeyBoadr 脚本 KeyBoard[] keyBoards = keyBoardRoot.GetComponentsInChildren<KeyBoard>(); // 初始化,建表 for (int i = 0; i < keyBoards.Length; i++) { keyBoadDic.Add(keyBoards[i].name, keyBoards[i]); } } // Start is called before the first frame update void Start() { // 获取 MplayerUnput、Animator 和 右手的 Transform、Rigidbody 的引用 playerInput = MPlayerInput.Single; animator = GetComponent<Animator>(); rightHand = animator.GetBoneTransform(HumanBodyBones.RightHand); rightHandRig = rightHand.GetComponent<Rigidbody>(); // 注册按键按下 和 松开的回调 playerInput.RegisterKeyDownCallBackSelfKey((key) => EnterKeyBoard(key)); playerInput.RegisterKeyUpCallBackSelfKey((key) => ReleaseKeyBoard(key)); } // 设置当前手掌要移动到的目标 // 为什么这么一句话要装成一个函数? // 因为这个方法是当时还没构思完整时就写上了,后面也懒得删掉了 private void SetTarget(Transform target) { this.target = target; } // 键盘按下时的回调 private void EnterKeyBoard(KeyCode key) { // 通过 ToString 获取键盘名 // 待优化:ToString 会产生 GC,虽然对于桌面精灵这样的小程序来说没什么大问题 // 可以在 字典初始化时使用 Enum.Parse 来把字符串转成枚举 // 这样在这里就不用 ToString 了 // 话说我有时间在这里写这些注释,干嘛不直接改了。 唉,就是这么懒 string keyStr = key.ToString(); // 安全判断 if (keyBoadDic.ContainsKey(keyStr)) { SetTarget(keyBoadDic[keyStr].transform); // 设置当前的手掌移动目标 keyBoadDic[keyStr].EnterKeyBoard(); // 调用对应按键的 KeyBoard 的 按下键盘方法(这么做是为了解耦,键盘就做键盘的事,角色就做角色的事) } } // 键盘抬起时的回调 private void ReleaseKeyBoard(KeyCode key) { // ToString 获取键盘名 // 与上面一样,可以进行优化 string keyStr = key.ToString(); // 安全判断 if(keyBoadDic.ContainsKey(keyStr)) keyBoadDic[keyStr].ReleaseKeyBoard(); // 调用对应按键的方法 } // LateUpdate 中负责执行对于骨骼控制的代码 private void LateUpdate() { // 安全判断,如果 target == null 就没必要移动骨骼 if (target == null) return; // 计算 目标按键 指向 手掌的向量 Vector3 t = rightHand.position - target.position; // 因为我们采用的是布娃娃系统来控制骨骼的运动 // 所以这里需要通过刚体来控制手掌的移动,让手掌朝着目标进行移动 // 这里采用设置速度(Velocity) 的方式 // 大家也可以试着用 AddForce 的方式 rightHandRig.velocity = (target.position - rightHand.position) * 200f * Time.fixedDeltaTime; // 将手掌缓动旋转至手掌朝向目标 rightHand.right = Vector3.Slerp(rightHand.right, t.normalized, Time.fixedDeltaTime); } private void OnAnimatorMove() { } }
代码中有 keyBoardRoot 这个引用。 keyBoardRoot 是一个在 键盘模型下的子物体:
KeyBoardPos物体
因为我的键盘模型并不是每个按键都是一个单独的物体,而是几个按键合并为一个网格的,所以我们需要用额外的一些空物体来记录每个按键的位置。
如上面一排的按键就是一整个物体
而我们所有用来记录键盘按键位置的物体,都作为这个 KeyBoardPos 的子物体:
KeyBoardPos下的子物体
可以发现,其下面的子物体都是由同一个预制体产生的,这样子可以节省我们游戏运行所占的内存。
让我来看一下这个预制体:
按键预制体
其上只挂在了一个 KeyBoard 脚本,自身的 Transform 记录了按键的位置。用于一个子物体,该子物体用于产生粒子系统。
和 2D桌面精灵 相同,这里我们不希望角色对于按键的响应只是把手移动到那里,而要再产生一些其它的反馈,来增强角色对于按键响应的反馈,因此这里我选择在按键被按下时,对应的按键会释放一个粒子特效。预制体的子物体 Visual Effect 就是用来释放粒子特效的。
子物体通过 VisualEffect 来产生粒子特效
粒子特效制作
先来看一下这个粒子特效的效果:
粒子特效
效果非常简单。从按键位置向上喷射粒子,然后通过粒子组成一个对应按键的形状。
该粒子特效通过 Visual Effect Graph 制作,接下来开始讲解制作过程。
首先我们需要根据按键字母的模型创建一个 point cache 文件
之前准备的字母模型
然后在 Unity 顶部工具栏选择 Window -> Visual Effects -> Utilities -> Point Cache Bake Tool 打开 point cache 烘焙窗口
开启过程
我们只需要网格的顶点位置信息即可,所以这样设置:
烘焙设置
然后点击 Save to pCache file... 按钮,即可生成并保存 point cache 文件
接下来创建一个 Visual effect graph:
创建的 point cache 和 visual effect graph
VFX连线图
关于 visual effect graph 制作粒子笔者这里不想讲的太细(讲太细又得另起一片专栏了),大致说一下制作思路。
首先将整个粒子效果拆分为两个部分或者说三个部分:
顶部字母的生成,我们可以通过刚才创建的 point cache 文件来设置 粒子的位置,这样即可让粒子组成我们想要的网格。
通过 point cache 来让粒子组成网格
关于 Point Cache 目前有一个缺陷,即我们无法创建一个 暴露在外的 Point Cache 变量。这导致我们要为每一个字母都去创建一个新的 Visual effect graph。这样操作起来会比较繁琐,并且会比较占用内存。
而从下向上喷射的粒子和其产生的细小粒子,这一大部分是直接通过 Visual Effect Graph 内置的 Heads & Sparks 模板修改而来。
右键 -> CreateNode -> System 可以看到 Unity 为我们准备了许多模板
更具体的细节,大家可以直接从我的 GitHub 上下载完整项目,来参考。
GitHub 地址:https://github.com/WhitePetal/Unity-Desktop-Wizard
说回到按键物体本身,我们还有个 KeyBoard 脚本没有介绍:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.VFX; public class KeyBoard : MonoBehaviour { // 存储粒子系统 private VisualEffect vfx; // Start is called before the first frame update void Start() { // 初始化粒子系统 vfx = GetComponentInChildren<VisualEffect>(); } // 该键被按下时调用 public void EnterKeyBoard() { vfx.Play(); // 开启粒子系统 } // 该键被松开时调用 public void ReleaseKeyBoard() { vfx.Stop(); // 关闭粒子系统 } }
代码非常简单,就是开启和关闭粒子系统。
至此,我们的 3D桌面精灵 的制作就讲解完毕了。
^_^